diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 29c12e2ca..b03a45f51 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -166,6 +166,29 @@ multi-frame pictures refused, the EXIF thumbnail dropped), one history entry for
- `Controls/CropMath` holds the arithmetic (ratios and their portrait forms, nudging by whole pixels, the frame in
pixels, where the size chip goes, where the dial opens) and is unit tested.
+## Clip window
+
+The Cut a clip tool (`tool-clip`, first on the back of the video donut) opens `Views/ClipWindow` instead of running a
+job. The window only chooses the clip; writing it is an ordinary conversion.
+
+- `Core/Presets/ClipCutter` does every edit without the window: the parts of the clip (start, end, speed, volume,
+ mute), gaps left by Delete, the blade, edge trims that take back or cut away, music on A2, fades, Remove silence and
+ undo / redo (one step per drag). The result is a `ClipEdit`, unit tested on its own.
+- `ClipWindowViewModel` holds the window's state: the overview of the whole video (`Controls/ClipTimeline`: 16
+ frames, the loudness, the handles) and the sequence under it (`Controls/ClipSequence`: V1 pictures, A1 the video's
+ sound, A2 music). Both are custom-drawn controls; their pictures and loudness are taken again only when the clip's
+ ends rest.
+- Space plays the clip as it will be written: `Engines/Media/ClipPreview` runs the same filter graph as the output,
+ the pictures as raw BGRA frames over a pipe and the sound through `ffplay` (when it sits next to Filee's FFmpeg;
+ without it the preview is silent). FFmpeg for the window runs below normal priority.
+- Save calls `ClipTool.ConversionOf`, which carries the `ClipEdit` in `MediaOptions.Clip`, so the toast, cancel, the
+ output rules (`{name}_clip` next to the video) and the history work as for any conversion. `FfmpegConverter`
+ copies one plain stretch with `-ss/-to` and `-c copy` (*Fast, no re-encoding*), or encodes the exact cut.
+- Anything else is one filter graph (`MediaEncoding.ClipGraphOf`): the video is read once, `split`/`trim` per piece,
+ `setpts` and chained `atempo` for speed, `volume`, `color`/`anullsrc` for gaps, `concat`, the music as a second
+ input (`atrim`, `volume`, `afade`, `adelay`, `amix`), then `fade`/`afade`. It is encoded with the preset's quality,
+ size and codec.
+
## Spreadsheet print studio
The `tool-sheet-studio` tool opens an independent `Views/SpreadsheetPrintStudioWindow` for each dropped XLSX workbook
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index d8921e125..da2ea4fd3 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -1417,6 +1417,8 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa
It is smaller than the "essentials" build (100 vs 115 MB download, 272 vs 329 MB on disk, because ffmpeg.exe and
ffprobe.exe share their libraries) and has a superset of its encoders. It links only Windows system DLLs; GPU
and Vulkan libraries are loaded only if present. Being over 60 MB, it is offered but not ticked on first run.
+- The Cut a clip window's preview plays its sound through `ffplay` when it sits next to Filee's FFmpeg (the gyan.dev
+ build has it); without it, the preview plays silently. Nothing else uses ffplay.
- Container and codecs per target (`Media/MediaEncoding.cs`, every encoder checked with `ffmpeg -encoders`):
| Target | Written as |
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index d25bce3c7..df65290f7 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -205,6 +205,66 @@
"crop.state_done": "تم الاقتصاص",
"crop.state_skipped": "تم التخطي",
"crop.state_failed": "فشل",
+ "preset.clip": "قص مقطع",
+ "clip.start": "البداية",
+ "clip.end": "النهاية",
+ "clip.length": "المدة",
+ "clip.timeline": "الخط الزمني",
+ "clip.save": "حفظ المقطع",
+ "clip.save_next": "حفظ والتالي",
+ "clip.seconds": "{0} ث",
+ "clip.length_hint": "مقطع بهذه المدة من البداية",
+ "clip.fast": "سريع، دون إعادة ترميز",
+ "clip.fast_hint": "ينسخ الفيديو كما هو: ينتهي في لحظة دون فقدان الجودة، لكن المقطع يبدأ من الإطار المفتاحي الذي يسبق البداية (حتى بضع ثوانٍ قبلها). أوقفه للقص بدقة عند الإطارات المختارة (يعيد الترميز ويستغرق وقتًا أطول).",
+ "clip.hint_keys": "المسافة للتشغيل · C للقص · Del للاقتطاع (فراغ)، Shift+Del للضم فورًا · اسحب عبر الأجزاء أو Ctrl / Shift+نقر لتحديد عدة أجزاء، Ctrl+A للكل · اسحب حافة الجزء لاستعادته أو تقصيره · ← → بمقدار 0.1 ث، ومع Shift ثانية · S / E / P البداية أو النهاية أو المؤشر · Ctrl+Z للتراجع · Z للتكبير · Enter للحفظ · Esc للإغلاق",
+ "clip.playhead": "مؤشر التشغيل",
+ "clip.play": "تشغيل مع الصوت (المسافة)",
+ "clip.pause": "إيقاف مؤقت (المسافة)",
+ "clip.fast_needs_encode": "توجد قصّات أو سرعة أو مستوى صوت أو تلاشٍ: سيُعاد ترميز المقطع، فلا يمكن نسخه كما هو.",
+ "clip.cut": "قص",
+ "clip.cut_hint": "قص الجزء إلى قسمين عند المؤشر (C)",
+ "clip.delete": "حذف",
+ "clip.restore": "استعادة",
+ "clip.delete_hint": "اقتطاع الجزء المحدد مع ترك فراغ وزر لإغلاقه (Delete)؛ Shift+Delete يضم الباقي فورًا. على A1 يكتم صوت الجزء، وعلى A2 يزيل الموسيقى",
+ "clip.undo": "تراجع (Ctrl+Z)",
+ "clip.redo": "إعادة (Ctrl+Y)",
+ "clip.magnet": "مغناطيس",
+ "clip.magnet_hint": "المغناطيس (M): يلتصق المؤشر وحواف الأجزاء والموسيقى بالقصّات وبالمؤشر",
+ "clip.zoom": "تكبير",
+ "clip.zoom_hint": "تكبير الخط الزمني على المقطع أو عرض الفيديو كله (Z)",
+ "clip.sequence": "التسلسل: الصورة والصوت والموسيقى",
+ "clip.close_gap": "إغلاق الفراغ",
+ "clip.no_music_yet": "A2: موسيقى من ملف (موسيقى…)",
+ "clip.remove_music": "إزالة الموسيقى",
+ "clip.mute": "كتم الصوت",
+ "clip.unmute": "إلغاء الكتم",
+ "clip.mute_hint": "كتم صوت الجزء المحدد مع بقاء الصورة (Delete على A1 يفعل ذلك أيضًا)",
+ "clip.music": "موسيقى",
+ "clip.music_hint": "وضع موسيقى من ملف تحت المقطع (A2): اسحبها لتحريكها واسحب خطها لمستوى الصوت",
+ "clip.music_pick": "موسيقى للمقطع",
+ "clip.music_files": "ملفات الصوت والفيديو",
+ "clip.no_music": "لا يحتوي هذا الملف على صوت يصلح موسيقى",
+ "clip.preparing": "جارٍ التحضير…",
+ "clip.finding_silence": "جارٍ البحث عن الصمت…",
+ "clip.loading_music": "جارٍ قراءة الموسيقى…",
+ "clip.selected_count": "المحدد: {0}",
+ "clip.volume": "مستوى الصوت",
+ "clip.volume_hint": "مستوى صوت الجزء المحدد (0–200 %)",
+ "clip.speed": "السرعة",
+ "clip.speed_hint": "سرعة الجزء المحدد؛ تبقى طبقة الصوت كما هي",
+ "clip.remove_silence": "إزالة الصمت",
+ "clip.remove_silence_hint": "إيجاد المقاطع الصامتة في المقطع واقتطاعها (Ctrl+Z يعيدها)",
+ "clip.close_gaps": "إزالة الفراغات",
+ "clip.close_gaps_hint": "إغلاق كل الفراغات التي خلّفتها الأجزاء المقتطعة",
+ "clip.fade_in": "ظهور تدريجي",
+ "clip.fade_in_hint": "الظهور من السواد والصمت خلال ثانية",
+ "clip.fade_out": "تلاشٍ",
+ "clip.fade_out_hint": "التلاشي إلى السواد والصمت خلال ثانية",
+ "clip.cannot_cut": "ضع المؤشر داخل المقطع، لا عند قصّة تمامًا",
+ "clip.last_piece": "لا يمكن اقتطاع آخر جزء يُشغَّل",
+ "clip.silence_removed": "أزيل الصمت: {0}",
+ "clip.no_silence": "لم يُعثر على صمت",
+ "clip.cannot_play": "لا يمكن تشغيل المقطع",
"preset.half_size": "نصف الحجم",
"preset.grayscale": "تدرّج رمادي",
"preset.remove_background": "إزالة الخلفية",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 3e8b61248..48abe0c6a 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -214,6 +214,66 @@
"crop.state_done": "Cropped",
"crop.state_skipped": "Skipped",
"crop.state_failed": "Failed",
+ "preset.clip": "Cut a clip",
+ "clip.start": "Start",
+ "clip.end": "End",
+ "clip.length": "Length",
+ "clip.timeline": "Timeline",
+ "clip.save": "Save clip",
+ "clip.save_next": "Save & next",
+ "clip.seconds": "{0} s",
+ "clip.length_hint": "A clip of this many seconds from the start",
+ "clip.fast": "Fast, no re-encoding",
+ "clip.fast_hint": "Copies the video as it is: done in a moment and nothing is lost, but the clip begins at the key frame before the start (up to a few seconds early). Turn off to cut exactly at the chosen frames (encodes again, takes longer).",
+ "clip.hint_keys": "Space plays · C cuts · Del cuts out (a gap), Shift+Del closes up · drag across pieces or Ctrl / Shift+click to select several, Ctrl+A all · drag a piece's edge to trim it · ← → by 0.1 s, Shift by 1 s · S / E / P start, end or playhead · Ctrl+Z undoes · Z zooms · Enter saves · Esc closes",
+ "clip.playhead": "Playhead",
+ "clip.play": "Play with sound (Space)",
+ "clip.pause": "Pause (Space)",
+ "clip.fast_needs_encode": "Cuts, speed, volume or fades: the clip is encoded again, so it can't be copied as it is.",
+ "clip.cut": "Cut",
+ "clip.cut_hint": "Cut the piece in two at the playhead (C)",
+ "clip.delete": "Delete",
+ "clip.restore": "Put back",
+ "clip.delete_hint": "Cut the selected piece out, leaving a gap with a button to close it (Delete); Shift+Delete closes up at once. On A1 it mutes the piece's sound, on A2 it removes the music",
+ "clip.undo": "Undo (Ctrl+Z)",
+ "clip.redo": "Redo (Ctrl+Y)",
+ "clip.magnet": "Magnet",
+ "clip.magnet_hint": "Magnet (M): the playhead, the piece edges and the music snap to cuts and to the playhead",
+ "clip.zoom": "Zoom",
+ "clip.zoom_hint": "Zoom the timeline in on the clip, or out to the whole video (Z)",
+ "clip.sequence": "Sequence: pictures, sound and music",
+ "clip.close_gap": "Close gap",
+ "clip.no_music_yet": "A2: music from a file (Music…)",
+ "clip.remove_music": "Remove music",
+ "clip.mute": "Mute sound",
+ "clip.unmute": "Unmute",
+ "clip.mute_hint": "Mute the selected piece's sound; its picture plays on (Delete on A1 does it too)",
+ "clip.music": "Music",
+ "clip.music_hint": "Put music from a file under the clip (A2): drag it along, drag its line for the volume",
+ "clip.music_pick": "Music for the clip",
+ "clip.music_files": "Sound and video files",
+ "clip.no_music": "This file has no sound to use as music",
+ "clip.preparing": "Preparing…",
+ "clip.finding_silence": "Finding silence…",
+ "clip.loading_music": "Reading the music…",
+ "clip.selected_count": "{0} selected",
+ "clip.volume": "Volume",
+ "clip.volume_hint": "Volume of the selected piece (0–200 %)",
+ "clip.speed": "Speed",
+ "clip.speed_hint": "Speed of the selected piece; the sound keeps its pitch",
+ "clip.remove_silence": "Remove silence",
+ "clip.remove_silence_hint": "Find the silent stretches of the clip and cut them out (Ctrl+Z puts them back)",
+ "clip.close_gaps": "Close gaps",
+ "clip.close_gaps_hint": "Close every gap left by pieces cut out",
+ "clip.fade_in": "Fade in",
+ "clip.fade_in_hint": "Fade in from black and silence over 1 second",
+ "clip.fade_out": "Fade out",
+ "clip.fade_out_hint": "Fade out to black and silence over 1 second",
+ "clip.cannot_cut": "Put the playhead inside the clip, not right at a cut",
+ "clip.last_piece": "The last piece that plays can't be cut out",
+ "clip.silence_removed": "Silence removed: {0}",
+ "clip.no_silence": "No silence found",
+ "clip.cannot_play": "The clip can't be played",
"preset.half_size": "Half size",
"preset.grayscale": "Grayscale",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index 82a946ecc..847a0fd81 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -205,6 +205,66 @@
"crop.state_done": "Recortado",
"crop.state_skipped": "Omitido",
"crop.state_failed": "Error",
+ "preset.clip": "Cortar un clip",
+ "clip.start": "Inicio",
+ "clip.end": "Fin",
+ "clip.length": "Duración",
+ "clip.timeline": "Línea de tiempo",
+ "clip.save": "Guardar clip",
+ "clip.save_next": "Guardar y siguiente",
+ "clip.seconds": "{0} s",
+ "clip.length_hint": "Un clip de estos segundos desde el inicio",
+ "clip.fast": "Rápido, sin recodificar",
+ "clip.fast_hint": "Copia el vídeo tal cual: listo al instante y sin pérdida, pero el clip empieza en el fotograma clave anterior al inicio (hasta unos segundos antes). Desactívalo para cortar exactamente en los fotogramas elegidos (vuelve a codificar, tarda más).",
+ "clip.hint_keys": "Espacio reproduce · C corta · Supr quita (hueco), Mayús+Supr cierra · arrastra sobre los fragmentos o Ctrl / Mayús+clic para elegir varios, Ctrl+A todos · arrastra el borde de un fragmento para recuperar o recortar · ← → 0,1 s, Mayús 1 s · S / E / P inicio, fin o cabezal · Ctrl+Z deshace · Z acerca · Intro guarda · Esc cierra",
+ "clip.playhead": "Cabezal",
+ "clip.play": "Reproducir con sonido (Espacio)",
+ "clip.pause": "Pausa (Espacio)",
+ "clip.fast_needs_encode": "Cortes, velocidad, volumen o fundidos: el clip se vuelve a codificar, no se puede copiar tal cual.",
+ "clip.cut": "Cortar",
+ "clip.cut_hint": "Cortar el fragmento en dos en el cabezal (C)",
+ "clip.delete": "Eliminar",
+ "clip.restore": "Restaurar",
+ "clip.delete_hint": "Quitar el fragmento elegido dejando un hueco con un botón para cerrarlo (Supr); Mayús+Supr lo cierra al momento. En A1 silencia el fragmento, en A2 quita la música",
+ "clip.undo": "Deshacer (Ctrl+Z)",
+ "clip.redo": "Rehacer (Ctrl+Y)",
+ "clip.magnet": "Imán",
+ "clip.magnet_hint": "Imán (M): el cabezal, los bordes de los fragmentos y la música se pegan a los cortes y al cabezal",
+ "clip.zoom": "Zoom",
+ "clip.zoom_hint": "Acercar la línea de tiempo al clip o mostrar todo el vídeo (Z)",
+ "clip.sequence": "Secuencia: imagen, sonido y música",
+ "clip.close_gap": "Cerrar hueco",
+ "clip.no_music_yet": "A2: música de un archivo (Música…)",
+ "clip.remove_music": "Quitar música",
+ "clip.mute": "Silenciar",
+ "clip.unmute": "Activar sonido",
+ "clip.mute_hint": "Silenciar el sonido del fragmento elegido; la imagen sigue (Supr en A1 hace lo mismo)",
+ "clip.music": "Música",
+ "clip.music_hint": "Poner música de un archivo bajo el clip (A2): arrástrala, arrastra su línea para el volumen",
+ "clip.music_pick": "Música para el clip",
+ "clip.music_files": "Archivos de sonido y vídeo",
+ "clip.no_music": "Este archivo no tiene sonido para usar como música",
+ "clip.preparing": "Preparando…",
+ "clip.finding_silence": "Buscando silencios…",
+ "clip.loading_music": "Leyendo la música…",
+ "clip.selected_count": "{0} seleccionados",
+ "clip.volume": "Volumen",
+ "clip.volume_hint": "Volumen del fragmento elegido (0–200 %)",
+ "clip.speed": "Velocidad",
+ "clip.speed_hint": "Velocidad del fragmento elegido; el sonido mantiene su tono",
+ "clip.remove_silence": "Quitar silencios",
+ "clip.remove_silence_hint": "Buscar los tramos en silencio del clip y quitarlos (Ctrl+Z los devuelve)",
+ "clip.close_gaps": "Cerrar huecos",
+ "clip.close_gaps_hint": "Cerrar todos los huecos que dejaron los fragmentos quitados",
+ "clip.fade_in": "Fundido de entrada",
+ "clip.fade_in_hint": "Aparece desde negro y silencio en 1 segundo",
+ "clip.fade_out": "Fundido de salida",
+ "clip.fade_out_hint": "Se funde a negro y silencio en 1 segundo",
+ "clip.cannot_cut": "Pon el cabezal dentro del clip, no justo en un corte",
+ "clip.last_piece": "No se puede quitar el último fragmento que suena",
+ "clip.silence_removed": "Silencios quitados: {0}",
+ "clip.no_silence": "No se encontraron silencios",
+ "clip.cannot_play": "No se puede reproducir el clip",
"preset.half_size": "Reducir 50 %",
"preset.grayscale": "Grises",
"preset.remove_background": "Quitar fondo",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index f003d2896..ce4c0312d 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -214,6 +214,66 @@
"crop.state_done": "Recadré",
"crop.state_skipped": "Ignoré",
"crop.state_failed": "Échec",
+ "preset.clip": "Couper un extrait",
+ "clip.start": "Début",
+ "clip.end": "Fin",
+ "clip.length": "Durée",
+ "clip.timeline": "Chronologie",
+ "clip.save": "Enregistrer l'extrait",
+ "clip.save_next": "Enregistrer et suivant",
+ "clip.seconds": "{0} s",
+ "clip.length_hint": "Un extrait de cette durée depuis le début",
+ "clip.fast": "Rapide, sans réencodage",
+ "clip.fast_hint": "Copie la vidéo telle quelle : c'est instantané et sans perte, mais l'extrait commence à l'image clé avant le début (jusqu'à quelques secondes plus tôt). Désactivez pour couper exactement aux images choisies (réencode, plus long).",
+ "clip.hint_keys": "Espace lit · C coupe · Suppr retire (laisse un vide), Maj+Suppr resserre · glissez sur les morceaux ou Ctrl / Maj+clic pour en choisir plusieurs, Ctrl+A tous · tirez le bord d'un morceau pour le rogner · ← → de 0,1 s, Maj de 1 s · S / E / P début, fin ou tête de lecture · Ctrl+Z annule · Z zoome · Entrée enregistre · Échap ferme",
+ "clip.playhead": "Tête de lecture",
+ "clip.play": "Lire avec le son (Espace)",
+ "clip.pause": "Pause (Espace)",
+ "clip.fast_needs_encode": "Coupes, vitesse, volume ou fondus : l'extrait est réencodé, il ne peut donc pas être copié tel quel.",
+ "clip.cut": "Couper",
+ "clip.cut_hint": "Couper le morceau en deux à la tête de lecture (C)",
+ "clip.delete": "Supprimer",
+ "clip.restore": "Remettre",
+ "clip.delete_hint": "Retirer le morceau choisi en laissant un vide avec un bouton pour le fermer (Suppr) ; Maj+Suppr resserre aussitôt. Sur A1, coupe le son du morceau ; sur A2, retire la musique",
+ "clip.undo": "Annuler (Ctrl+Z)",
+ "clip.redo": "Rétablir (Ctrl+Y)",
+ "clip.magnet": "Aimant",
+ "clip.magnet_hint": "Aimant (M) : la tête de lecture, les bords des morceaux et la musique s'accrochent aux coupes et à la tête de lecture",
+ "clip.zoom": "Zoom",
+ "clip.zoom_hint": "Zoomer la chronologie sur l'extrait, ou revenir à toute la vidéo (Z)",
+ "clip.sequence": "Séquence : image, son et musique",
+ "clip.close_gap": "Fermer le vide",
+ "clip.no_music_yet": "A2 : musique d'un fichier (Musique…)",
+ "clip.remove_music": "Retirer la musique",
+ "clip.mute": "Couper le son",
+ "clip.unmute": "Rétablir le son",
+ "clip.mute_hint": "Couper le son du morceau choisi ; l'image continue (Suppr sur A1 fait de même)",
+ "clip.music": "Musique",
+ "clip.music_hint": "Mettre la musique d'un fichier sous l'extrait (A2) : faites-la glisser, tirez sa ligne pour le volume",
+ "clip.music_pick": "Musique pour l'extrait",
+ "clip.music_files": "Fichiers son et vidéo",
+ "clip.no_music": "Ce fichier n'a pas de son à utiliser comme musique",
+ "clip.preparing": "Préparation…",
+ "clip.finding_silence": "Recherche des silences…",
+ "clip.loading_music": "Lecture de la musique…",
+ "clip.selected_count": "{0} sélectionnés",
+ "clip.volume": "Volume",
+ "clip.volume_hint": "Volume du morceau choisi (0–200 %)",
+ "clip.speed": "Vitesse",
+ "clip.speed_hint": "Vitesse du morceau choisi ; le son garde sa hauteur",
+ "clip.remove_silence": "Retirer les silences",
+ "clip.remove_silence_hint": "Trouver les passages silencieux de l'extrait et les retirer (Ctrl+Z les remet)",
+ "clip.close_gaps": "Fermer les vides",
+ "clip.close_gaps_hint": "Fermer tous les vides laissés par les morceaux retirés",
+ "clip.fade_in": "Fondu d'entrée",
+ "clip.fade_in_hint": "Apparaître depuis le noir et le silence en 1 seconde",
+ "clip.fade_out": "Fondu de sortie",
+ "clip.fade_out_hint": "Disparaître dans le noir et le silence en 1 seconde",
+ "clip.cannot_cut": "Placez la tête de lecture dans l'extrait, pas exactement sur une coupe",
+ "clip.last_piece": "Le dernier morceau qui joue ne peut pas être retiré",
+ "clip.silence_removed": "Silences retirés : {0}",
+ "clip.no_silence": "Aucun silence trouvé",
+ "clip.cannot_play": "Impossible de lire l'extrait",
"preset.half_size": "Taille 50 %",
"preset.grayscale": "Niveaux de gris",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index 2bc111941..faf6e8126 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -205,6 +205,66 @@
"crop.state_done": "क्रॉप हो गया",
"crop.state_skipped": "छोड़ा गया",
"crop.state_failed": "विफल",
+ "preset.clip": "क्लिप काटें",
+ "clip.start": "शुरुआत",
+ "clip.end": "अंत",
+ "clip.length": "अवधि",
+ "clip.timeline": "टाइमलाइन",
+ "clip.save": "क्लिप सहेजें",
+ "clip.save_next": "सहेजें और अगला",
+ "clip.seconds": "{0} से.",
+ "clip.length_hint": "शुरुआत से इतने सेकंड की क्लिप",
+ "clip.fast": "तेज़, बिना री-एन्कोड",
+ "clip.fast_hint": "वीडियो को जैसा है वैसा कॉपी करता है: पल भर में और बिना गुणवत्ता खोए, पर क्लिप शुरुआत से पहले वाले की फ़्रेम से शुरू होती है (कुछ सेकंड पहले तक)। चुने गए फ़्रेम पर ठीक-ठीक काटने के लिए बंद करें (दोबारा एन्कोड होता है, ज़्यादा समय लगता है)।",
+ "clip.hint_keys": "Space चलाए · C काटे · Del हटाए (खाली जगह), Shift+Del तुरंत जोड़े · कई चुनने के लिए टुकड़ों पर खींचें या Ctrl / Shift+क्लिक, Ctrl+A सभी · वापस लाने या छाँटने के लिए किनारा खींचें · ← → 0.1 से., Shift से 1 से. · S / E / P शुरुआत, अंत या प्लेहेड · Ctrl+Z पूर्ववत · Z ज़ूम · Enter सहेजे · Esc बंद करे",
+ "clip.playhead": "प्लेहेड",
+ "clip.play": "आवाज़ के साथ चलाएँ (Space)",
+ "clip.pause": "रोकें (Space)",
+ "clip.fast_needs_encode": "कट, गति, आवाज़ या फ़ेड: क्लिप दोबारा एन्कोड होगी, इसलिए जैसी है वैसी कॉपी नहीं हो सकती।",
+ "clip.cut": "काटें",
+ "clip.cut_hint": "प्लेहेड पर टुकड़े को दो में काटें (C)",
+ "clip.delete": "हटाएँ",
+ "clip.restore": "वापस लाएँ",
+ "clip.delete_hint": "चुना गया टुकड़ा हटाएँ, खाली जगह और उसे बंद करने का बटन रहेगा (Delete); Shift+Delete तुरंत जोड़ दे। A1 पर आवाज़ बंद, A2 पर संगीत हटे",
+ "clip.undo": "पूर्ववत करें (Ctrl+Z)",
+ "clip.redo": "फिर से करें (Ctrl+Y)",
+ "clip.magnet": "चुंबक",
+ "clip.magnet_hint": "चुंबक (M): प्लेहेड, टुकड़ों के किनारे और संगीत कट और प्लेहेड से चिपकते हैं",
+ "clip.zoom": "ज़ूम",
+ "clip.zoom_hint": "टाइमलाइन को क्लिप पर ज़ूम करें या पूरा वीडियो दिखाएँ (Z)",
+ "clip.sequence": "सीक्वेंस: चित्र, आवाज़ और संगीत",
+ "clip.close_gap": "खाली जगह बंद करें",
+ "clip.no_music_yet": "A2: फ़ाइल से संगीत (संगीत…)",
+ "clip.remove_music": "संगीत हटाएँ",
+ "clip.mute": "आवाज़ बंद",
+ "clip.unmute": "आवाज़ चालू",
+ "clip.mute_hint": "चुने गए टुकड़े की आवाज़ बंद करें; चित्र चलता रहे (A1 पर Delete भी यही करता है)",
+ "clip.music": "संगीत",
+ "clip.music_hint": "फ़ाइल का संगीत क्लिप के नीचे लगाएँ (A2): खींचकर खिसकाएँ, रेखा खींचकर आवाज़ बदलें",
+ "clip.music_pick": "क्लिप के लिए संगीत",
+ "clip.music_files": "ध्वनि और वीडियो फ़ाइलें",
+ "clip.no_music": "इस फ़ाइल में संगीत के लिए आवाज़ नहीं है",
+ "clip.preparing": "तैयारी हो रही है…",
+ "clip.finding_silence": "चुप्पी ढूँढ रहे हैं…",
+ "clip.loading_music": "संगीत पढ़ रहे हैं…",
+ "clip.selected_count": "{0} चुने गए",
+ "clip.volume": "आवाज़",
+ "clip.volume_hint": "चुने गए टुकड़े की आवाज़ (0–200 %)",
+ "clip.speed": "गति",
+ "clip.speed_hint": "चुने गए टुकड़े की गति; आवाज़ की पिच वही रहती है",
+ "clip.remove_silence": "चुप्पी हटाएँ",
+ "clip.remove_silence_hint": "क्लिप में शांत हिस्से ढूँढकर काटें (Ctrl+Z से वापस)",
+ "clip.close_gaps": "खाली जगह हटाएँ",
+ "clip.close_gaps_hint": "हटाए गए टुकड़ों की सारी खाली जगह बंद करें",
+ "clip.fade_in": "फ़ेड इन",
+ "clip.fade_in_hint": "1 सेकंड में काले और चुप्पी से उभरें",
+ "clip.fade_out": "फ़ेड आउट",
+ "clip.fade_out_hint": "1 सेकंड में काले और चुप्पी में ढलें",
+ "clip.cannot_cut": "प्लेहेड को क्लिप के अंदर रखें, ठीक कट पर नहीं",
+ "clip.last_piece": "आखिरी चलने वाला टुकड़ा हटाया नहीं जा सकता",
+ "clip.silence_removed": "चुप्पी हटाई गई: {0}",
+ "clip.no_silence": "कोई चुप्पी नहीं मिली",
+ "clip.cannot_play": "क्लिप नहीं चल सकती",
"preset.half_size": "आधा आकार",
"preset.grayscale": "ग्रेस्केल",
"preset.remove_background": "बैकग्राउंड हटाएँ",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index 73e163db5..9de6257ce 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -205,6 +205,66 @@
"crop.state_done": "トリミング済み",
"crop.state_skipped": "スキップ",
"crop.state_failed": "失敗",
+ "preset.clip": "クリップを切り出す",
+ "clip.start": "開始",
+ "clip.end": "終了",
+ "clip.length": "長さ",
+ "clip.timeline": "タイムライン",
+ "clip.save": "クリップを保存",
+ "clip.save_next": "保存して次へ",
+ "clip.seconds": "{0} 秒",
+ "clip.length_hint": "開始位置からこの長さのクリップ",
+ "clip.fast": "高速(再エンコードなし)",
+ "clip.fast_hint": "動画をそのままコピーします。すぐに終わり画質も落ちませんが、クリップは開始位置の前のキーフレームから始まります(最大数秒早く)。オフにすると選んだフレームで正確に切り出します(再エンコードするため時間がかかります)。",
+ "clip.hint_keys": "Space で再生 · C でカット · Del で切り取り(空白)、Shift+Del で詰める · 区間をドラッグか Ctrl / Shift+クリックで複数選択、Ctrl+A ですべて · 区間の端をドラッグで戻す・詰める · ← → で 0.1 秒、Shift で 1 秒 · S / E / P で開始・終了・再生ヘッド · Ctrl+Z で元に戻す · Z でズーム · Enter で保存 · Esc で閉じる",
+ "clip.playhead": "再生ヘッド",
+ "clip.play": "音声付きで再生(Space)",
+ "clip.pause": "一時停止(Space)",
+ "clip.fast_needs_encode": "カット、速度、音量、フェードがあるため再エンコードします。そのままコピーはできません。",
+ "clip.cut": "カット",
+ "clip.cut_hint": "再生ヘッドの位置で区間を2つに分ける(C)",
+ "clip.delete": "削除",
+ "clip.restore": "元に戻す",
+ "clip.delete_hint": "選んだ区間を切り取り、空白と「空白を詰める」ボタンを残します(Delete)。Shift+Delete はすぐ詰めます。A1 では音声をミュート、A2 では音楽を外します",
+ "clip.undo": "元に戻す(Ctrl+Z)",
+ "clip.redo": "やり直し(Ctrl+Y)",
+ "clip.magnet": "マグネット",
+ "clip.magnet_hint": "マグネット(M):再生ヘッド、区間の端、音楽がカット位置と再生ヘッドに吸着します",
+ "clip.zoom": "ズーム",
+ "clip.zoom_hint": "タイムラインをクリップに拡大、または動画全体を表示(Z)",
+ "clip.sequence": "シーケンス:映像・音声・音楽",
+ "clip.close_gap": "空白を詰める",
+ "clip.no_music_yet": "A2:ファイルの音楽(音楽…)",
+ "clip.remove_music": "音楽を外す",
+ "clip.mute": "音声をミュート",
+ "clip.unmute": "ミュート解除",
+ "clip.mute_hint": "選んだ区間の音声を消し、映像はそのまま(A1 で Delete も同じ)",
+ "clip.music": "音楽",
+ "clip.music_hint": "ファイルの音楽をクリップの下に置く(A2):ドラッグで移動、線をドラッグで音量",
+ "clip.music_pick": "クリップの音楽",
+ "clip.music_files": "音声・動画ファイル",
+ "clip.no_music": "このファイルには音楽に使える音声がありません",
+ "clip.preparing": "準備中…",
+ "clip.finding_silence": "無音を検出中…",
+ "clip.loading_music": "音楽を読み込み中…",
+ "clip.selected_count": "{0} 個を選択",
+ "clip.volume": "音量",
+ "clip.volume_hint": "選んだ区間の音量(0–200 %)",
+ "clip.speed": "速度",
+ "clip.speed_hint": "選んだ区間の速度。音の高さは変わりません",
+ "clip.remove_silence": "無音を削除",
+ "clip.remove_silence_hint": "クリップの無音部分を見つけて切り取る(Ctrl+Z で戻せます)",
+ "clip.close_gaps": "空白を詰める",
+ "clip.close_gaps_hint": "切り取った区間が残した空白をすべて詰める",
+ "clip.fade_in": "フェードイン",
+ "clip.fade_in_hint": "1 秒かけて黒と無音から現れる",
+ "clip.fade_out": "フェードアウト",
+ "clip.fade_out_hint": "1 秒かけて黒と無音へ消える",
+ "clip.cannot_cut": "再生ヘッドをカット位置ではなくクリップの中に置いてください",
+ "clip.last_piece": "最後に再生される区間は切り取れません",
+ "clip.silence_removed": "無音を削除しました: {0}",
+ "clip.no_silence": "無音は見つかりませんでした",
+ "clip.cannot_play": "クリップを再生できません",
"preset.half_size": "縮小 50%",
"preset.grayscale": "グレースケール",
"preset.remove_background": "背景を削除",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 6c5e3549a..79a14b732 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -214,6 +214,66 @@
"crop.state_done": "자르기 완료",
"crop.state_skipped": "건너뜀",
"crop.state_failed": "실패",
+ "preset.clip": "클립 자르기",
+ "clip.start": "시작",
+ "clip.end": "끝",
+ "clip.length": "길이",
+ "clip.timeline": "타임라인",
+ "clip.save": "클립 저장",
+ "clip.save_next": "저장 후 다음",
+ "clip.seconds": "{0}초",
+ "clip.length_hint": "시작 지점부터 이 길이만큼의 클립",
+ "clip.fast": "빠르게, 재인코딩 없이",
+ "clip.fast_hint": "영상을 그대로 복사합니다. 금방 끝나고 화질 손실이 없지만, 클립은 시작 지점 앞의 키 프레임에서 시작합니다(최대 몇 초 일찍). 끄면 고른 프레임에서 정확히 자릅니다(다시 인코딩하므로 더 오래 걸림).",
+ "clip.hint_keys": "Space 재생 · C 자르기 · Del 잘라내기(빈 자리), Shift+Del 바로 붙이기 · 조각을 끌어 지나가거나 Ctrl / Shift+클릭으로 여러 개 선택, Ctrl+A 전체 · 조각 가장자리를 끌어 되돌리거나 다듬기 · ← → 0.1초, Shift는 1초 · S / E / P 시작, 끝, 재생 헤드 · Ctrl+Z 실행 취소 · Z 확대 · Enter 저장 · Esc 닫기",
+ "clip.playhead": "재생 헤드",
+ "clip.play": "소리와 함께 재생 (Space)",
+ "clip.pause": "일시 정지 (Space)",
+ "clip.fast_needs_encode": "자르기, 속도, 음량이나 페이드가 있어 다시 인코딩하므로 그대로 복사할 수 없어요.",
+ "clip.cut": "자르기",
+ "clip.cut_hint": "재생 헤드 위치에서 조각을 둘로 자르기 (C)",
+ "clip.delete": "삭제",
+ "clip.restore": "되돌리기",
+ "clip.delete_hint": "선택한 조각을 잘라내면 빈 자리와 닫기 버튼이 남아요 (Delete). Shift+Delete는 바로 붙여요. A1에서는 소리를 끄고, A2에서는 음악을 빼요",
+ "clip.undo": "실행 취소 (Ctrl+Z)",
+ "clip.redo": "다시 실행 (Ctrl+Y)",
+ "clip.magnet": "자석",
+ "clip.magnet_hint": "자석 (M): 재생 헤드, 조각 가장자리, 음악이 자른 곳과 재생 헤드에 붙어요",
+ "clip.zoom": "확대",
+ "clip.zoom_hint": "타임라인을 클립에 맞춰 확대하거나 전체 영상으로 보기 (Z)",
+ "clip.sequence": "시퀀스: 화면, 소리, 음악",
+ "clip.close_gap": "빈 자리 없애기",
+ "clip.no_music_yet": "A2: 파일의 음악 (음악…)",
+ "clip.remove_music": "음악 빼기",
+ "clip.mute": "소리 끄기",
+ "clip.unmute": "소리 켜기",
+ "clip.mute_hint": "선택한 조각의 소리를 끄고 화면은 그대로 둬요 (A1에서 Delete도 같아요)",
+ "clip.music": "음악",
+ "clip.music_hint": "파일의 음악을 클립 아래에 깔기 (A2): 끌어서 옮기고, 선을 끌어 음량 조절",
+ "clip.music_pick": "클립에 넣을 음악",
+ "clip.music_files": "소리 및 동영상 파일",
+ "clip.no_music": "이 파일에는 음악으로 쓸 소리가 없어요",
+ "clip.preparing": "준비 중…",
+ "clip.finding_silence": "무음 찾는 중…",
+ "clip.loading_music": "음악 읽는 중…",
+ "clip.selected_count": "{0}개 선택됨",
+ "clip.volume": "음량",
+ "clip.volume_hint": "선택한 조각의 음량 (0–200 %)",
+ "clip.speed": "속도",
+ "clip.speed_hint": "선택한 조각의 속도. 소리 높이는 그대로예요",
+ "clip.remove_silence": "무음 제거",
+ "clip.remove_silence_hint": "클립에서 조용한 구간을 찾아 잘라내기 (Ctrl+Z로 되돌림)",
+ "clip.close_gaps": "빈 자리 없애기",
+ "clip.close_gaps_hint": "잘라낸 조각이 남긴 빈 자리를 모두 없애기",
+ "clip.fade_in": "페이드 인",
+ "clip.fade_in_hint": "1초 동안 검은 화면과 무음에서 나타나기",
+ "clip.fade_out": "페이드 아웃",
+ "clip.fade_out_hint": "1초 동안 검은 화면과 무음으로 사라지기",
+ "clip.cannot_cut": "재생 헤드를 자른 곳이 아닌 클립 안에 두세요",
+ "clip.last_piece": "마지막으로 재생되는 조각은 잘라낼 수 없어요",
+ "clip.silence_removed": "무음 구간 제거: {0}",
+ "clip.no_silence": "무음 구간이 없어요",
+ "clip.cannot_play": "클립을 재생할 수 없어요",
"preset.half_size": "50% 축소",
"preset.grayscale": "흑백",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index c912c166e..1546a0a2c 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -205,6 +205,66 @@
"crop.state_done": "क्रप गरियो",
"crop.state_skipped": "छोडियो",
"crop.state_failed": "असफल भयो",
+ "preset.clip": "क्लिप काट्नुहोस्",
+ "clip.start": "सुरु",
+ "clip.end": "अन्त्य",
+ "clip.length": "लम्बाइ",
+ "clip.timeline": "टाइमलाइन",
+ "clip.save": "क्लिप सेभ गर्नुहोस्",
+ "clip.save_next": "सेभ गरेर अर्को",
+ "clip.seconds": "{0} से",
+ "clip.length_hint": "सुरुबाट यति सेकेन्डको क्लिप",
+ "clip.fast": "छिटो, फेरि इन्कोड नगरी",
+ "clip.fast_hint": "भिडियो जस्ताको तस्तै प्रतिलिपि गर्छ: एकै छिनमा हुन्छ र केही गुम्दैन, तर क्लिप सुरुभन्दा अघिको की फ्रेमबाट सुरु हुन्छ (केही सेकेन्डसम्म अघि)। छानिएकै फ्रेममा ठ्याक्कै काट्न यो बन्द गर्नुहोस् (फेरि इन्कोड हुन्छ, बढी समय लाग्छ)।",
+ "clip.hint_keys": "Space बजाउँछ · C काट्छ · Del हटाउँछ (खाली ठाउँ रहन्छ), Shift+Del जोड्छ · धेरै छान्न टुक्राहरूमाथि तान्नुहोस् वा Ctrl / Shift+क्लिक, Ctrl+A सबै · छाँट्न टुक्राको किनारा तान्नुहोस् · ← → 0.1 से, Shift 1 से · S / E / P सुरु, अन्त्य वा प्लेहेड · Ctrl+Z पूर्वावस्था · Z जुम · Enter सेभ · Esc बन्द",
+ "clip.playhead": "प्लेहेड",
+ "clip.play": "आवाजसहित बजाउनुहोस् (Space)",
+ "clip.pause": "रोक्नुहोस् (Space)",
+ "clip.fast_needs_encode": "काटाइ, गति, आवाज वा फेड छ: क्लिप फेरि इन्कोड हुन्छ, त्यसैले जस्ताको तस्तै प्रतिलिपि गर्न सकिँदैन।",
+ "clip.cut": "काट्नुहोस्",
+ "clip.cut_hint": "प्लेहेडमा टुक्रालाई दुई भाग गर्नुहोस् (C)",
+ "clip.delete": "मेटाउनुहोस्",
+ "clip.restore": "फर्काउनुहोस्",
+ "clip.delete_hint": "छानिएको टुक्रा हटाउनुहोस्, जोड्ने बटनसहित खाली ठाउँ रहन्छ (Delete); Shift+Delete तुरुन्तै जोड्छ। A1 मा टुक्राको आवाज बन्द गर्छ, A2 मा सङ्गीत हटाउँछ",
+ "clip.undo": "पूर्वावस्था (Ctrl+Z)",
+ "clip.redo": "फेरि गर्नुहोस् (Ctrl+Y)",
+ "clip.magnet": "चुम्बक",
+ "clip.magnet_hint": "चुम्बक (M): प्लेहेड, टुक्राका किनारा र सङ्गीत काटिएका ठाउँ र प्लेहेडमा टाँसिन्छन्",
+ "clip.zoom": "जुम",
+ "clip.zoom_hint": "टाइमलाइनलाई क्लिपमा जुम गर्नुहोस्, वा पूरै भिडियो देखाउनुहोस् (Z)",
+ "clip.sequence": "सिक्वेन्स: दृश्य, आवाज र सङ्गीत",
+ "clip.close_gap": "खाली ठाउँ जोड्नुहोस्",
+ "clip.no_music_yet": "A2: फाइलबाट सङ्गीत (सङ्गीत…)",
+ "clip.remove_music": "सङ्गीत हटाउनुहोस्",
+ "clip.mute": "आवाज बन्द",
+ "clip.unmute": "आवाज खोल्नुहोस्",
+ "clip.mute_hint": "छानिएको टुक्राको आवाज बन्द गर्नुहोस्; दृश्य चलिरहन्छ (A1 मा Delete ले पनि यही गर्छ)",
+ "clip.music": "सङ्गीत",
+ "clip.music_hint": "क्लिपमुनि फाइलको सङ्गीत राख्नुहोस् (A2): सार्न तान्नुहोस्, आवाजका लागि रेखा तान्नुहोस्",
+ "clip.music_pick": "क्लिपका लागि सङ्गीत",
+ "clip.music_files": "आवाज र भिडियो फाइलहरू",
+ "clip.no_music": "यो फाइलमा सङ्गीतका रूपमा प्रयोग गर्ने आवाज छैन",
+ "clip.preparing": "तयार गर्दै…",
+ "clip.finding_silence": "मौनता खोज्दै…",
+ "clip.loading_music": "सङ्गीत पढ्दै…",
+ "clip.selected_count": "{0} छानिए",
+ "clip.volume": "आवाज",
+ "clip.volume_hint": "छानिएको टुक्राको आवाज (0–200 %)",
+ "clip.speed": "गति",
+ "clip.speed_hint": "छानिएको टुक्राको गति; आवाजको सुर उस्तै रहन्छ",
+ "clip.remove_silence": "मौनता हटाउनुहोस्",
+ "clip.remove_silence_hint": "क्लिपका मौन भागहरू खोजेर हटाउनुहोस् (Ctrl+Z ले फर्काउँछ)",
+ "clip.close_gaps": "खाली ठाउँहरू जोड्नुहोस्",
+ "clip.close_gaps_hint": "हटाइएका टुक्राले छोडेका सबै खाली ठाउँ जोड्नुहोस्",
+ "clip.fade_in": "फेड इन",
+ "clip.fade_in_hint": "1 सेकेन्डमा कालो र मौनताबाट देखा पर्नुहोस्",
+ "clip.fade_out": "फेड आउट",
+ "clip.fade_out_hint": "1 सेकेन्डमा कालो र मौनतामा हराउनुहोस्",
+ "clip.cannot_cut": "प्लेहेडलाई क्लिपभित्र राख्नुहोस्, ठ्याक्कै काटिएको ठाउँमा होइन",
+ "clip.last_piece": "बज्ने अन्तिम टुक्रा हटाउन सकिँदैन",
+ "clip.silence_removed": "मौनता हटाइयो: {0}",
+ "clip.no_silence": "मौनता भेटिएन",
+ "clip.cannot_play": "क्लिप बजाउन सकिँदैन",
"preset.half_size": "आधा आकार",
"preset.grayscale": "ग्रेस्केल",
"preset.remove_background": "पृष्ठभूमि हटाउनुहोस्",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 19fd9162e..c2adac993 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -214,6 +214,66 @@
"crop.state_done": "Wykadrowany",
"crop.state_skipped": "Pominięty",
"crop.state_failed": "Błąd",
+ "preset.clip": "Wytnij klip",
+ "clip.start": "Początek",
+ "clip.end": "Koniec",
+ "clip.length": "Długość",
+ "clip.timeline": "Oś czasu",
+ "clip.save": "Zapisz klip",
+ "clip.save_next": "Zapisz i dalej",
+ "clip.seconds": "{0} s",
+ "clip.length_hint": "Klip o tej długości od początku",
+ "clip.fast": "Szybko, bez ponownego kodowania",
+ "clip.fast_hint": "Kopiuje wideo bez zmian: gotowe w chwilę i bez utraty jakości, ale klip zaczyna się od klatki kluczowej przed początkiem (do kilku sekund wcześniej). Wyłącz, aby ciąć dokładnie w wybranych klatkach (koduje ponownie, trwa dłużej).",
+ "clip.hint_keys": "Spacja odtwarza · C tnie · Del wycina (luka), Shift+Del zsuwa · przeciągnij po fragmentach lub Ctrl / Shift+klik, by zaznaczyć kilka, Ctrl+A wszystkie · przeciągnij krawędź fragmentu, by przywrócić lub przyciąć · ← → o 0,1 s, z Shift o 1 s · S / E / P początek, koniec lub głowica · Ctrl+Z cofa · Z przybliża · Enter zapisuje · Esc zamyka",
+ "clip.playhead": "Głowica",
+ "clip.play": "Odtwórz z dźwiękiem (Spacja)",
+ "clip.pause": "Pauza (Spacja)",
+ "clip.fast_needs_encode": "Cięcia, prędkość, głośność lub przejścia: klip zostanie zakodowany ponownie, nie da się go skopiować bez zmian.",
+ "clip.cut": "Tnij",
+ "clip.cut_hint": "Przetnij fragment w miejscu głowicy (C)",
+ "clip.delete": "Usuń",
+ "clip.restore": "Przywróć",
+ "clip.delete_hint": "Wytnij wybrany fragment, zostawiając lukę z przyciskiem do jej zamknięcia (Delete); Shift+Delete od razu zsuwa. Na A1 wycisza fragment, na A2 usuwa muzykę",
+ "clip.undo": "Cofnij (Ctrl+Z)",
+ "clip.redo": "Ponów (Ctrl+Y)",
+ "clip.magnet": "Magnes",
+ "clip.magnet_hint": "Magnes (M): głowica, krawędzie fragmentów i muzyka przyciągają się do cięć i głowicy",
+ "clip.zoom": "Powiększ",
+ "clip.zoom_hint": "Przybliż oś czasu do klipu lub pokaż całe wideo (Z)",
+ "clip.sequence": "Sekwencja: obraz, dźwięk i muzyka",
+ "clip.close_gap": "Zamknij lukę",
+ "clip.no_music_yet": "A2: muzyka z pliku (Muzyka…)",
+ "clip.remove_music": "Usuń muzykę",
+ "clip.mute": "Wycisz",
+ "clip.unmute": "Przywróć dźwięk",
+ "clip.mute_hint": "Wycisz dźwięk wybranego fragmentu; obraz gra dalej (Delete na A1 robi to samo)",
+ "clip.music": "Muzyka",
+ "clip.music_hint": "Podłóż muzykę z pliku pod klip (A2): przeciągaj ją, przeciągnij linię, by zmienić głośność",
+ "clip.music_pick": "Muzyka do klipu",
+ "clip.music_files": "Pliki dźwięku i wideo",
+ "clip.no_music": "Ten plik nie ma dźwięku do użycia jako muzyka",
+ "clip.preparing": "Przygotowanie…",
+ "clip.finding_silence": "Szukam ciszy…",
+ "clip.loading_music": "Wczytuję muzykę…",
+ "clip.selected_count": "Zaznaczono: {0}",
+ "clip.volume": "Głośność",
+ "clip.volume_hint": "Głośność wybranego fragmentu (0–200 %)",
+ "clip.speed": "Prędkość",
+ "clip.speed_hint": "Prędkość wybranego fragmentu; dźwięk zachowuje wysokość",
+ "clip.remove_silence": "Usuń ciszę",
+ "clip.remove_silence_hint": "Znajdź ciche miejsca w klipie i wytnij je (Ctrl+Z przywraca)",
+ "clip.close_gaps": "Zamknij luki",
+ "clip.close_gaps_hint": "Zamknij wszystkie luki po wyciętych fragmentach",
+ "clip.fade_in": "Pojawianie",
+ "clip.fade_in_hint": "Pojawienie się z czerni i ciszy w 1 sekundę",
+ "clip.fade_out": "Wygaszanie",
+ "clip.fade_out_hint": "Wygaszenie do czerni i ciszy w 1 sekundę",
+ "clip.cannot_cut": "Ustaw głowicę wewnątrz klipu, nie dokładnie na cięciu",
+ "clip.last_piece": "Ostatniego grającego fragmentu nie można wyciąć",
+ "clip.silence_removed": "Usunięto ciszę: {0}",
+ "clip.no_silence": "Nie znaleziono ciszy",
+ "clip.cannot_play": "Nie można odtworzyć klipu",
"preset.half_size": "Połowa rozmiaru",
"preset.grayscale": "Skala szarości",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index f55c0f4a8..42c64df23 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -194,6 +194,7 @@
"profile.mixed": "Arquivos mistos",
"preset.crop": "Recortar",
+ "preset.clip": "Cortar um clipe",
"crop.save": "Recortar e salvar",
"crop.reset": "Redefinir",
"crop.skip": "Pular",
@@ -214,6 +215,65 @@
"crop.state_done": "Recortado",
"crop.state_skipped": "Pulado",
"crop.state_failed": "Falhou",
+ "clip.start": "Início",
+ "clip.end": "Fim",
+ "clip.length": "Duração",
+ "clip.timeline": "Linha do tempo",
+ "clip.save": "Salvar clipe",
+ "clip.save_next": "Salvar e próximo",
+ "clip.seconds": "{0} s",
+ "clip.length_hint": "Um clipe com esta quantidade de segundos a partir do início",
+ "clip.fast": "Rápido, sem recodificar",
+ "clip.fast_hint": "Copia o vídeo como está: pronto num instante e sem perda, mas o clipe começa no quadro-chave antes do início (até alguns segundos antes). Desative para cortar exatamente nos quadros escolhidos (recodifica, demora mais).",
+ "clip.hint_keys": "Espaço reproduz · C corta · Del remove (deixa um vão), Shift+Del fecha na hora · arraste sobre os trechos ou Ctrl / Shift+clique para escolher vários, Ctrl+A todos · arraste a borda de um trecho para recuperar ou aparar · ← → 0,1 s, Shift 1 s · S / E / P início, fim ou cursor · Ctrl+Z desfaz · Z aproxima · Enter salva · Esc fecha",
+ "clip.playhead": "Cursor",
+ "clip.play": "Reproduzir com som (Espaço)",
+ "clip.pause": "Pausar (Espaço)",
+ "clip.fast_needs_encode": "Cortes, velocidade, volume ou transições: o clipe é recodificado, não pode ser copiado como está.",
+ "clip.cut": "Cortar",
+ "clip.cut_hint": "Cortar o trecho em dois no cursor (C)",
+ "clip.delete": "Remover",
+ "clip.restore": "Recolocar",
+ "clip.delete_hint": "Remove o trecho escolhido e deixa um vão com um botão para fechá-lo (Delete); Shift+Delete fecha na hora. Em A1 silencia o trecho, em A2 remove a música",
+ "clip.undo": "Desfazer (Ctrl+Z)",
+ "clip.redo": "Refazer (Ctrl+Y)",
+ "clip.magnet": "Ímã",
+ "clip.magnet_hint": "Ímã (M): o cursor, as bordas dos trechos e a música grudam nos cortes e no cursor",
+ "clip.zoom": "Zoom",
+ "clip.zoom_hint": "Aproximar a linha do tempo do clipe, ou mostrar o vídeo inteiro (Z)",
+ "clip.sequence": "Sequência: imagem, som e música",
+ "clip.close_gap": "Fechar vão",
+ "clip.no_music_yet": "A2: música de um arquivo (Música…)",
+ "clip.remove_music": "Remover música",
+ "clip.mute": "Silenciar",
+ "clip.unmute": "Ativar som",
+ "clip.mute_hint": "Silenciar o som do trecho escolhido; a imagem continua (Delete em A1 faz o mesmo)",
+ "clip.music": "Música",
+ "clip.music_hint": "Colocar música de um arquivo sob o clipe (A2): arraste para mover, arraste a linha para o volume",
+ "clip.music_pick": "Música para o clipe",
+ "clip.music_files": "Arquivos de som e vídeo",
+ "clip.no_music": "Este arquivo não tem som para usar como música",
+ "clip.preparing": "Preparando…",
+ "clip.finding_silence": "Procurando silêncio…",
+ "clip.loading_music": "Lendo a música…",
+ "clip.selected_count": "{0} selecionados",
+ "clip.volume": "Volume",
+ "clip.volume_hint": "Volume do trecho escolhido (0–200 %)",
+ "clip.speed": "Velocidade",
+ "clip.speed_hint": "Velocidade do trecho escolhido; o som mantém o tom",
+ "clip.remove_silence": "Remover silêncio",
+ "clip.remove_silence_hint": "Encontrar os trechos silenciosos do clipe e removê-los (Ctrl+Z os traz de volta)",
+ "clip.close_gaps": "Fechar vãos",
+ "clip.close_gaps_hint": "Fechar todos os vãos deixados por trechos removidos",
+ "clip.fade_in": "Entrada suave",
+ "clip.fade_in_hint": "Surgir do preto e do silêncio em 1 segundo",
+ "clip.fade_out": "Saída suave",
+ "clip.fade_out_hint": "Sumir para o preto e o silêncio em 1 segundo",
+ "clip.cannot_cut": "Coloque o cursor dentro do clipe, não exatamente num corte",
+ "clip.last_piece": "O último trecho que toca não pode ser removido",
+ "clip.silence_removed": "Silêncio removido: {0}",
+ "clip.no_silence": "Nenhum silêncio encontrado",
+ "clip.cannot_play": "Não foi possível reproduzir o clipe",
"preset.half_size": "Reduzir 50%",
"preset.grayscale": "Tons de cinza",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index db9fbd85e..b48880eb6 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -214,6 +214,66 @@
"crop.state_done": "Обрезан",
"crop.state_skipped": "Пропущен",
"crop.state_failed": "Ошибка",
+ "preset.clip": "Вырезать клип",
+ "clip.start": "Начало",
+ "clip.end": "Конец",
+ "clip.length": "Длина",
+ "clip.timeline": "Шкала времени",
+ "clip.save": "Сохранить клип",
+ "clip.save_next": "Сохранить и далее",
+ "clip.seconds": "{0} с",
+ "clip.length_hint": "Клип такой длины от начала",
+ "clip.fast": "Быстро, без перекодирования",
+ "clip.fast_hint": "Копирует видео как есть: мгновенно и без потери качества, но клип начинается с ключевого кадра перед началом (до нескольких секунд раньше). Выключите, чтобы резать точно по выбранным кадрам (с перекодированием, дольше).",
+ "clip.hint_keys": "Пробел — играть · C — разрезать · Del — вырезать (пустота), Shift+Del — сразу сдвинуть · протяните по кускам или Ctrl / Shift+клик — выбрать несколько, Ctrl+A — все · тяните край куска, чтобы вернуть или подрезать · ← → на 0,1 с, Shift — на 1 с · S / E / P — начало, конец или указатель · Ctrl+Z — отменить · Z — приблизить · Enter — сохранить · Esc — закрыть",
+ "clip.playhead": "Указатель",
+ "clip.play": "Воспроизвести со звуком (Пробел)",
+ "clip.pause": "Пауза (Пробел)",
+ "clip.fast_needs_encode": "Разрезы, скорость, громкость или затухания: клип перекодируется, скопировать как есть нельзя.",
+ "clip.cut": "Разрезать",
+ "clip.cut_hint": "Разрезать кусок в месте указателя (C)",
+ "clip.delete": "Удалить",
+ "clip.restore": "Вернуть",
+ "clip.delete_hint": "Вырезать выбранный кусок — на его месте пустота с кнопкой «Убрать пустоту» (Delete); Shift+Delete сразу сдвигает остальное. На A1 выключает звук куска, на A2 убирает музыку",
+ "clip.undo": "Отменить (Ctrl+Z)",
+ "clip.redo": "Повторить (Ctrl+Y)",
+ "clip.magnet": "Магнит",
+ "clip.magnet_hint": "Магнит (M): указатель, края кусков и музыка прилипают к разрезам и к указателю",
+ "clip.zoom": "Приблизить",
+ "clip.zoom_hint": "Приблизить шкалу к клипу или показать всё видео (Z)",
+ "clip.sequence": "Монтаж: картинка, звук и музыка",
+ "clip.close_gap": "Убрать пустоту",
+ "clip.no_music_yet": "A2: музыка из файла (Музыка…)",
+ "clip.remove_music": "Убрать музыку",
+ "clip.mute": "Без звука",
+ "clip.unmute": "Вернуть звук",
+ "clip.mute_hint": "Выключить звук выбранного куска, картинка остаётся (Delete на дорожке A1 делает то же)",
+ "clip.music": "Музыка",
+ "clip.music_hint": "Подложить музыку из файла под клип (дорожка A2): двигайте её мышкой, тяните линию — громкость",
+ "clip.music_pick": "Музыка для клипа",
+ "clip.music_files": "Звук и видео",
+ "clip.no_music": "В этом файле нет звука для музыки",
+ "clip.preparing": "Подготовка…",
+ "clip.finding_silence": "Ищу тишину…",
+ "clip.loading_music": "Читаю музыку…",
+ "clip.selected_count": "Выбрано: {0}",
+ "clip.volume": "Громкость",
+ "clip.volume_hint": "Громкость выбранного куска (0–200 %)",
+ "clip.speed": "Скорость",
+ "clip.speed_hint": "Скорость выбранного куска; высота звука не меняется",
+ "clip.remove_silence": "Убрать тишину",
+ "clip.remove_silence_hint": "Найти тихие места в клипе и вырезать их (Ctrl+Z вернёт)",
+ "clip.close_gaps": "Убрать пустоты",
+ "clip.close_gaps_hint": "Закрыть все пустоты, оставшиеся от вырезанных кусков",
+ "clip.fade_in": "Нарастание",
+ "clip.fade_in_hint": "Плавное появление из чёрного и тишины за 1 секунду",
+ "clip.fade_out": "Затухание",
+ "clip.fade_out_hint": "Плавное затухание в чёрное и тишину за 1 секунду",
+ "clip.cannot_cut": "Поставьте указатель внутрь клипа, не на сам разрез",
+ "clip.last_piece": "Последний звучащий кусок вырезать нельзя",
+ "clip.silence_removed": "Убрано тихих мест: {0}",
+ "clip.no_silence": "Тишины не найдено",
+ "clip.cannot_play": "Клип не воспроизводится",
"preset.half_size": "Вдвое меньше",
"preset.grayscale": "Оттенки серого",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index b586b53e3..c17f3e505 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -205,6 +205,66 @@
"crop.state_done": "ครอบตัดแล้ว",
"crop.state_skipped": "ข้ามแล้ว",
"crop.state_failed": "ล้มเหลว",
+ "preset.clip": "ตัดคลิป",
+ "clip.start": "เริ่ม",
+ "clip.end": "สิ้นสุด",
+ "clip.length": "ความยาว",
+ "clip.timeline": "ไทม์ไลน์",
+ "clip.save": "บันทึกคลิป",
+ "clip.save_next": "บันทึกและต่อไป",
+ "clip.seconds": "{0} วินาที",
+ "clip.length_hint": "คลิปยาวเท่านี้วินาทีนับจากจุดเริ่ม",
+ "clip.fast": "เร็ว ไม่เข้ารหัสใหม่",
+ "clip.fast_hint": "คัดลอกวิดีโอตามเดิม: เสร็จในพริบตาและไม่สูญเสียคุณภาพ แต่คลิปจะเริ่มที่คีย์เฟรมก่อนจุดเริ่ม (เร็วกว่าได้ถึงไม่กี่วินาที) ปิดตัวเลือกนี้เพื่อตัดตรงเฟรมที่เลือกพอดี (เข้ารหัสใหม่ ใช้เวลานานกว่า)",
+ "clip.hint_keys": "Space เล่น · C ตัด · Del ตัดออก (เว้นช่องว่าง), Shift+Del ปิดช่องว่าง · ลากผ่านหลายชิ้นหรือ Ctrl / Shift+คลิกเพื่อเลือกหลายชิ้น, Ctrl+A เลือกทั้งหมด · ลากขอบชิ้นเพื่อตัดแต่ง · ← → ทีละ 0.1 วินาที, Shift ทีละ 1 วินาที · S / E / P จุดเริ่ม จุดสิ้นสุด หรือหัวเล่น · Ctrl+Z เลิกทำ · Z ซูม · Enter บันทึก · Esc ปิด",
+ "clip.playhead": "หัวเล่น",
+ "clip.play": "เล่นพร้อมเสียง (Space)",
+ "clip.pause": "หยุดชั่วคราว (Space)",
+ "clip.fast_needs_encode": "มีการตัด ความเร็ว ระดับเสียง หรือการเฟด: คลิปจะถูกเข้ารหัสใหม่ จึงคัดลอกตามเดิมไม่ได้",
+ "clip.cut": "ตัด",
+ "clip.cut_hint": "ตัดชิ้นเป็นสองส่วนที่หัวเล่น (C)",
+ "clip.delete": "ลบ",
+ "clip.restore": "ใส่กลับ",
+ "clip.delete_hint": "ตัดชิ้นที่เลือกออก โดยเว้นช่องว่างพร้อมปุ่มสำหรับปิดช่องว่าง (Delete); Shift+Delete ปิดช่องว่างทันที บน A1 จะปิดเสียงของชิ้นนั้น บน A2 จะลบเพลง",
+ "clip.undo": "เลิกทำ (Ctrl+Z)",
+ "clip.redo": "ทำซ้ำ (Ctrl+Y)",
+ "clip.magnet": "แม่เหล็ก",
+ "clip.magnet_hint": "แม่เหล็ก (M): หัวเล่น ขอบชิ้น และเพลงจะดูดติดกับจุดตัดและหัวเล่น",
+ "clip.zoom": "ซูม",
+ "clip.zoom_hint": "ซูมไทม์ไลน์เข้าที่คลิป หรือออกให้เห็นวิดีโอทั้งหมด (Z)",
+ "clip.sequence": "ลำดับ: ภาพ เสียง และเพลง",
+ "clip.close_gap": "ปิดช่องว่าง",
+ "clip.no_music_yet": "A2: เพลงจากไฟล์ (เพลง…)",
+ "clip.remove_music": "ลบเพลง",
+ "clip.mute": "ปิดเสียง",
+ "clip.unmute": "เปิดเสียง",
+ "clip.mute_hint": "ปิดเสียงของชิ้นที่เลือก ภาพยังเล่นต่อ (Delete บน A1 ก็ทำได้เช่นกัน)",
+ "clip.music": "เพลง",
+ "clip.music_hint": "ใส่เพลงจากไฟล์ใต้คลิป (A2): ลากเพื่อเลื่อน ลากเส้นเพื่อปรับระดับเสียง",
+ "clip.music_pick": "เพลงสำหรับคลิป",
+ "clip.music_files": "ไฟล์เสียงและวิดีโอ",
+ "clip.no_music": "ไฟล์นี้ไม่มีเสียงที่จะใช้เป็นเพลงได้",
+ "clip.preparing": "กำลังเตรียม…",
+ "clip.finding_silence": "กำลังหาช่วงเงียบ…",
+ "clip.loading_music": "กำลังอ่านเพลง…",
+ "clip.selected_count": "เลือก {0} ชิ้น",
+ "clip.volume": "ระดับเสียง",
+ "clip.volume_hint": "ระดับเสียงของชิ้นที่เลือก (0–200 %)",
+ "clip.speed": "ความเร็ว",
+ "clip.speed_hint": "ความเร็วของชิ้นที่เลือก เสียงยังคงระดับเสียงสูงต่ำเดิม",
+ "clip.remove_silence": "ลบช่วงเงียบ",
+ "clip.remove_silence_hint": "หาช่วงที่เงียบในคลิปแล้วตัดออก (Ctrl+Z ใส่กลับ)",
+ "clip.close_gaps": "ปิดช่องว่าง",
+ "clip.close_gaps_hint": "ปิดช่องว่างทั้งหมดที่เหลือจากชิ้นที่ตัดออก",
+ "clip.fade_in": "เฟดเข้า",
+ "clip.fade_in_hint": "เฟดเข้าจากภาพดำและความเงียบภายใน 1 วินาที",
+ "clip.fade_out": "เฟดออก",
+ "clip.fade_out_hint": "เฟดออกเป็นภาพดำและความเงียบภายใน 1 วินาที",
+ "clip.cannot_cut": "วางหัวเล่นไว้ภายในคลิป ไม่ใช่ตรงจุดตัดพอดี",
+ "clip.last_piece": "ตัดชิ้นสุดท้ายที่เล่นอยู่ออกไม่ได้",
+ "clip.silence_removed": "ลบช่วงเงียบแล้ว: {0}",
+ "clip.no_silence": "ไม่พบช่วงเงียบ",
+ "clip.cannot_play": "เล่นคลิปนี้ไม่ได้",
"preset.half_size": "ขนาดครึ่งหนึ่ง",
"preset.grayscale": "ขาวดำ (เกรย์สเกล)",
"preset.remove_background": "ลบพื้นหลัง",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index a8a4a5bf4..1bde238f0 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -205,6 +205,66 @@
"crop.state_done": "Kırpıldı",
"crop.state_skipped": "Atlandı",
"crop.state_failed": "Başarısız",
+ "preset.clip": "Klip kes",
+ "clip.start": "Başlangıç",
+ "clip.end": "Bitiş",
+ "clip.length": "Uzunluk",
+ "clip.timeline": "Zaman çizelgesi",
+ "clip.save": "Klibi kaydet",
+ "clip.save_next": "Kaydet ve ilerle",
+ "clip.seconds": "{0} sn",
+ "clip.length_hint": "Başlangıçtan itibaren bu kadar saniyelik bir klip",
+ "clip.fast": "Hızlı, yeniden kodlama yok",
+ "clip.fast_hint": "Videoyu olduğu gibi kopyalar: bir anda biter ve hiçbir şey kaybolmaz, ancak klip başlangıçtan önceki anahtar karede başlar (birkaç saniyeye kadar erken). Tam seçilen karelerden kesmek için kapatın (yeniden kodlar, daha uzun sürer).",
+ "clip.hint_keys": "Boşluk oynatır · C keser · Del parçayı çıkarır (boşluk kalır), Shift+Del boşluğu kapatır · birden fazla seçmek için parçaların üzerinden sürükleyin veya Ctrl / Shift+tıklayın, Ctrl+A hepsini seçer · kırpmak için parçanın kenarını sürükleyin · ← → 0,1 sn, Shift ile 1 sn · S / E / P başlangıç, bitiş veya oynatma çubuğu · Ctrl+Z geri alır · Z yakınlaştırır · Enter kaydeder · Esc kapatır",
+ "clip.playhead": "Oynatma çubuğu",
+ "clip.play": "Sesli oynat (Boşluk)",
+ "clip.pause": "Duraklat (Boşluk)",
+ "clip.fast_needs_encode": "Kesimler, hız, ses düzeyi veya geçişler var: klip yeniden kodlanır, bu yüzden olduğu gibi kopyalanamaz.",
+ "clip.cut": "Kes",
+ "clip.cut_hint": "Parçayı oynatma çubuğunda ikiye böl (C)",
+ "clip.delete": "Sil",
+ "clip.restore": "Geri koy",
+ "clip.delete_hint": "Seçili parçayı çıkarır ve kapatmak için düğmesi olan bir boşluk bırakır (Delete); Shift+Delete boşluğu hemen kapatır. A1'de parçanın sesini kapatır, A2'de müziği kaldırır",
+ "clip.undo": "Geri al (Ctrl+Z)",
+ "clip.redo": "Yinele (Ctrl+Y)",
+ "clip.magnet": "Mıknatıs",
+ "clip.magnet_hint": "Mıknatıs (M): oynatma çubuğu, parça kenarları ve müzik kesimlere ve oynatma çubuğuna yapışır",
+ "clip.zoom": "Yakınlaştır",
+ "clip.zoom_hint": "Zaman çizelgesini klibe yakınlaştırır veya tüm videoya uzaklaştırır (Z)",
+ "clip.sequence": "Sıra: görüntü, ses ve müzik",
+ "clip.close_gap": "Boşluğu kapat",
+ "clip.no_music_yet": "A2: dosyadan müzik (Müzik…)",
+ "clip.remove_music": "Müziği kaldır",
+ "clip.mute": "Sesi kapat",
+ "clip.unmute": "Sesi aç",
+ "clip.mute_hint": "Seçili parçanın sesini kapatır; görüntüsü oynamaya devam eder (A1'de Delete de bunu yapar)",
+ "clip.music": "Müzik",
+ "clip.music_hint": "Klibin altına bir dosyadan müzik koyun (A2): kaydırmak için sürükleyin, ses düzeyi için çizgisini sürükleyin",
+ "clip.music_pick": "Klip için müzik",
+ "clip.music_files": "Ses ve video dosyaları",
+ "clip.no_music": "Bu dosyada müzik olarak kullanılacak ses yok",
+ "clip.preparing": "Hazırlanıyor…",
+ "clip.finding_silence": "Sessiz kısımlar aranıyor…",
+ "clip.loading_music": "Müzik okunuyor…",
+ "clip.selected_count": "{0} seçili",
+ "clip.volume": "Ses düzeyi",
+ "clip.volume_hint": "Seçili parçanın ses düzeyi (%0–200)",
+ "clip.speed": "Hız",
+ "clip.speed_hint": "Seçili parçanın hızı; ses perdesini korur",
+ "clip.remove_silence": "Sessizliği kaldır",
+ "clip.remove_silence_hint": "Klibin sessiz kısımlarını bulup çıkarır (Ctrl+Z geri koyar)",
+ "clip.close_gaps": "Boşlukları kapat",
+ "clip.close_gaps_hint": "Çıkarılan parçaların bıraktığı tüm boşlukları kapatır",
+ "clip.fade_in": "Açılış geçişi",
+ "clip.fade_in_hint": "1 saniyede siyahtan ve sessizlikten açılır",
+ "clip.fade_out": "Kapanış geçişi",
+ "clip.fade_out_hint": "1 saniyede siyaha ve sessizliğe kararır",
+ "clip.cannot_cut": "Oynatma çubuğunu bir kesimin tam üstüne değil, klibin içine getirin",
+ "clip.last_piece": "Oynayan son parça çıkarılamaz",
+ "clip.silence_removed": "Sessizlik kaldırıldı: {0}",
+ "clip.no_silence": "Sessiz kısım bulunamadı",
+ "clip.cannot_play": "Klip oynatılamıyor",
"preset.half_size": "Yarı boyut",
"preset.grayscale": "Gri tonlama",
"preset.remove_background": "Arka planı kaldır",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 41345af8a..6f1808b85 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -214,6 +214,66 @@
"crop.state_done": "已裁剪",
"crop.state_skipped": "已跳过",
"crop.state_failed": "失败",
+ "preset.clip": "剪切片段",
+ "clip.start": "开始",
+ "clip.end": "结束",
+ "clip.length": "时长",
+ "clip.timeline": "时间轴",
+ "clip.save": "保存片段",
+ "clip.save_next": "保存并下一个",
+ "clip.seconds": "{0} 秒",
+ "clip.length_hint": "从开始位置起这么长的片段",
+ "clip.fast": "快速,不重新编码",
+ "clip.fast_hint": "按原样复制视频:瞬间完成且无画质损失,但片段从开始位置之前的关键帧开始(最多提前几秒)。关闭后会在所选帧处精确剪切(需要重新编码,耗时更长)。",
+ "clip.hint_keys": "空格 播放 · C 剪切 · Del 剪掉(留空白),Shift+Del 立即合拢 · 在片段上拖动或 Ctrl / Shift+单击可多选,Ctrl+A 全选 · 拖动片段边缘可恢复或修剪 · ← → 移动 0.1 秒,Shift 1 秒 · S / E / P 开始、结束或播放头 · Ctrl+Z 撤销 · Z 缩放 · Enter 保存 · Esc 关闭",
+ "clip.playhead": "播放头",
+ "clip.play": "带声音播放(空格)",
+ "clip.pause": "暂停(空格)",
+ "clip.fast_needs_encode": "有剪切、速度、音量或淡入淡出,片段会重新编码,无法原样复制。",
+ "clip.cut": "剪切",
+ "clip.cut_hint": "在播放头处把片段一分为二(C)",
+ "clip.delete": "删除",
+ "clip.restore": "恢复",
+ "clip.delete_hint": "剪掉所选片段,留下空白和“合拢空白”按钮(Delete);Shift+Delete 立即合拢。在 A1 上静音片段,在 A2 上移除音乐",
+ "clip.undo": "撤销(Ctrl+Z)",
+ "clip.redo": "重做(Ctrl+Y)",
+ "clip.magnet": "磁铁",
+ "clip.magnet_hint": "磁铁(M):播放头、片段边缘和音乐吸附到剪切点和播放头",
+ "clip.zoom": "缩放",
+ "clip.zoom_hint": "将时间轴放大到片段,或显示整个视频(Z)",
+ "clip.sequence": "序列:画面、声音和音乐",
+ "clip.close_gap": "合拢空白",
+ "clip.no_music_yet": "A2:文件中的音乐(音乐…)",
+ "clip.remove_music": "移除音乐",
+ "clip.mute": "静音",
+ "clip.unmute": "取消静音",
+ "clip.mute_hint": "关闭所选片段的声音,画面照常播放(在 A1 上按 Delete 也一样)",
+ "clip.music": "音乐",
+ "clip.music_hint": "把文件中的音乐垫在片段下面(A2):拖动来移动,拖动线条调音量",
+ "clip.music_pick": "片段的音乐",
+ "clip.music_files": "声音和视频文件",
+ "clip.no_music": "此文件没有可用作音乐的声音",
+ "clip.preparing": "准备中…",
+ "clip.finding_silence": "正在查找静音…",
+ "clip.loading_music": "正在读取音乐…",
+ "clip.selected_count": "已选择 {0} 个",
+ "clip.volume": "音量",
+ "clip.volume_hint": "所选片段的音量(0–200 %)",
+ "clip.speed": "速度",
+ "clip.speed_hint": "所选片段的速度,音调不变",
+ "clip.remove_silence": "去除静音",
+ "clip.remove_silence_hint": "找出片段中的静音部分并剪掉(Ctrl+Z 可恢复)",
+ "clip.close_gaps": "合拢空白",
+ "clip.close_gaps_hint": "合拢剪掉片段留下的所有空白",
+ "clip.fade_in": "淡入",
+ "clip.fade_in_hint": "1 秒内从黑屏和静音中淡入",
+ "clip.fade_out": "淡出",
+ "clip.fade_out_hint": "1 秒内淡出到黑屏和静音",
+ "clip.cannot_cut": "请把播放头放在片段内,不要正好在剪切点上",
+ "clip.last_piece": "最后一个播放的片段不能剪掉",
+ "clip.silence_removed": "已去除静音:{0} 处",
+ "clip.no_silence": "没有找到静音",
+ "clip.cannot_play": "无法播放片段",
"preset.half_size": "缩小 50%",
"preset.grayscale": "灰度",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index b4b5968af..a8656dc5a 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -214,6 +214,66 @@
"crop.state_done": "已裁剪",
"crop.state_skipped": "已跳過",
"crop.state_failed": "失敗",
+ "preset.clip": "剪輯片段",
+ "clip.start": "開始",
+ "clip.end": "結束",
+ "clip.length": "長度",
+ "clip.timeline": "時間軸",
+ "clip.save": "儲存片段",
+ "clip.save_next": "儲存並下一個",
+ "clip.seconds": "{0} 秒",
+ "clip.length_hint": "從開始位置起這麼長的片段",
+ "clip.fast": "快速,不重新編碼",
+ "clip.fast_hint": "按原樣複製影片:瞬間完成且畫質無損,但片段會從開始位置之前的關鍵影格開始(最多提早幾秒)。關閉後會在所選影格精確剪輯(需要重新編碼,耗時較長)。",
+ "clip.hint_keys": "空白鍵 播放 · C 剪開 · Del 剪掉(留空白),Shift+Del 立即合攏 · 在片段上拖曳或 Ctrl / Shift+按一下可多選,Ctrl+A 全選 · 拖曳片段邊緣可修剪 · ← → 移動 0.1 秒,Shift 1 秒 · S / E / P 開始、結束或播放頭 · Ctrl+Z 復原 · Z 縮放 · Enter 儲存 · Esc 關閉",
+ "clip.playhead": "播放頭",
+ "clip.play": "帶聲音播放(空白鍵)",
+ "clip.pause": "暫停(空白鍵)",
+ "clip.fast_needs_encode": "有剪輯、速度、音量或淡入淡出,片段會重新編碼,無法按原樣複製。",
+ "clip.cut": "剪開",
+ "clip.cut_hint": "在播放頭處把片段一分為二(C)",
+ "clip.delete": "刪除",
+ "clip.restore": "放回",
+ "clip.delete_hint": "剪掉所選片段,留下空白和「合攏空白」按鈕(Delete);Shift+Delete 立即合攏。在 A1 上會將片段靜音,在 A2 上會移除音樂",
+ "clip.undo": "復原(Ctrl+Z)",
+ "clip.redo": "取消復原(Ctrl+Y)",
+ "clip.magnet": "磁鐵",
+ "clip.magnet_hint": "磁鐵(M):播放頭、片段邊緣和音樂會吸附到剪輯點和播放頭",
+ "clip.zoom": "縮放",
+ "clip.zoom_hint": "將時間軸放大到片段,或顯示整部影片(Z)",
+ "clip.sequence": "序列:畫面、聲音和音樂",
+ "clip.close_gap": "合攏空白",
+ "clip.no_music_yet": "A2:檔案中的音樂(音樂…)",
+ "clip.remove_music": "移除音樂",
+ "clip.mute": "靜音",
+ "clip.unmute": "取消靜音",
+ "clip.mute_hint": "關閉所選片段的聲音,畫面照常播放(在 A1 上按 Delete 也一樣)",
+ "clip.music": "音樂",
+ "clip.music_hint": "把檔案中的音樂墊在片段下面(A2):拖曳來移動,拖曳線條調整音量",
+ "clip.music_pick": "片段的音樂",
+ "clip.music_files": "聲音和影片檔案",
+ "clip.no_music": "這個檔案沒有可用作音樂的聲音",
+ "clip.preparing": "準備中…",
+ "clip.finding_silence": "正在尋找靜音…",
+ "clip.loading_music": "正在讀取音樂…",
+ "clip.selected_count": "已選取 {0} 個",
+ "clip.volume": "音量",
+ "clip.volume_hint": "所選片段的音量(0–200 %)",
+ "clip.speed": "速度",
+ "clip.speed_hint": "所選片段的速度,音調不變",
+ "clip.remove_silence": "去除靜音",
+ "clip.remove_silence_hint": "找出片段中的靜音部分並剪掉(Ctrl+Z 可放回)",
+ "clip.close_gaps": "合攏空白",
+ "clip.close_gaps_hint": "合攏剪掉片段後留下的所有空白",
+ "clip.fade_in": "淡入",
+ "clip.fade_in_hint": "1 秒內從黑畫面和靜音中淡入",
+ "clip.fade_out": "淡出",
+ "clip.fade_out_hint": "1 秒內淡出到黑畫面和靜音",
+ "clip.cannot_cut": "請把播放頭放在片段內,不要剛好在剪輯點上",
+ "clip.last_piece": "最後一個播放的片段不能剪掉",
+ "clip.silence_removed": "已去除靜音:{0} 處",
+ "clip.no_silence": "沒有找到靜音",
+ "clip.cannot_play": "無法播放片段",
"preset.half_size": "縮小 50%",
"preset.grayscale": "灰階",
diff --git a/src/Filee.App/Controls/ClipSequence.cs b/src/Filee.App/Controls/ClipSequence.cs
new file mode 100644
index 000000000..669f82823
--- /dev/null
+++ b/src/Filee.App/Controls/ClipSequence.cs
@@ -0,0 +1,799 @@
+// The clip window's sequence, as in an editor: the clip as it plays, its pieces one after the other on three tracks.
+// V1 the pictures, A1 the video's own sound (its loudness, a volume line to drag, muted pieces hatched), A2 the music
+// from another file (dragged along, its own volume line). A piece cut out leaves a gap with a button to close it; a
+// piece's edges are dragged to trim it (outwards it takes back what was cut next to it). The ruler and a click put the
+// playhead; with the magnet on, the playhead, edges and music snap to cuts and to the playhead. Time runs left to right
+// in every language.
+
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Filee.App.ViewModels;
+using Filee.Core.Presets;
+
+namespace Filee.App.Controls;
+
+/// The tracks of the sequence.
+public enum ClipLane
+{
+ Video,
+ Audio,
+ Music,
+}
+
+/// How a click selects a piece: alone, added or taken out (Ctrl), or a run from the last one (Shift).
+public enum ClipSelect
+{
+ Only,
+ Toggle,
+ Range,
+}
+
+public sealed class ClipSequence : Control
+{
+ public static readonly StyledProperty?> PiecesProperty =
+ AvaloniaProperty.Register?>(nameof(Pieces));
+
+ /// The playhead, in seconds into the clip.
+ public static readonly StyledProperty PlayheadProperty =
+ AvaloniaProperty.Register(nameof(Playhead));
+
+ public static readonly StyledProperty SelectedIndexProperty =
+ AvaloniaProperty.Register(nameof(SelectedIndex), -1);
+
+ /// Every selected piece; empty: just .
+ public static readonly StyledProperty?> SelectedIndicesProperty =
+ AvaloniaProperty.Register?>(nameof(SelectedIndices));
+
+ public static readonly StyledProperty SelectedLaneProperty =
+ AvaloniaProperty.Register(nameof(SelectedLane));
+
+ /// Pictures of the video from to , evenly spread.
+ public static readonly StyledProperty?> ThumbnailsProperty =
+ AvaloniaProperty.Register?>(nameof(Thumbnails));
+
+ /// The loudness of the video's sound from to .
+ public static readonly StyledProperty?> LoudnessProperty =
+ AvaloniaProperty.Register?>(nameof(Loudness));
+
+ public static readonly StyledProperty DetailStartProperty =
+ AvaloniaProperty.Register(nameof(DetailStart));
+
+ public static readonly StyledProperty DetailEndProperty =
+ AvaloniaProperty.Register(nameof(DetailEnd));
+
+ public static readonly StyledProperty MusicProperty =
+ AvaloniaProperty.Register(nameof(Music));
+
+ /// The loudness of the whole music file.
+ public static readonly StyledProperty?> MusicLoudnessProperty =
+ AvaloniaProperty.Register?>(nameof(MusicLoudness));
+
+ public static readonly StyledProperty FadeInProperty =
+ AvaloniaProperty.Register(nameof(FadeIn));
+
+ public static readonly StyledProperty FadeOutProperty =
+ AvaloniaProperty.Register(nameof(FadeOut));
+
+ public static readonly StyledProperty SnapProperty =
+ AvaloniaProperty.Register(nameof(Snap), true);
+
+ /// The words on a gap's button ("Close gap").
+ public static readonly StyledProperty GapTextProperty =
+ AvaloniaProperty.Register(nameof(GapText), "Close gap");
+
+ /// Written on the music track when there is none ("Music…: add a file").
+ public static readonly StyledProperty NoMusicTextProperty =
+ AvaloniaProperty.Register(nameof(NoMusicText));
+
+ public static readonly StyledProperty AccentBrushProperty =
+ AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple);
+
+ public static readonly StyledProperty OnAccentBrushProperty =
+ AvaloniaProperty.Register(nameof(OnAccentBrush), Brushes.White);
+
+ public static readonly StyledProperty TrackBrushProperty =
+ AvaloniaProperty.Register(nameof(TrackBrush), Brushes.LightGray);
+
+ public static readonly StyledProperty StageBrushProperty =
+ AvaloniaProperty.Register(nameof(StageBrush), Brushes.White);
+
+ public static readonly StyledProperty ForegroundProperty =
+ AvaloniaProperty.Register(nameof(Foreground), Brushes.Black);
+
+ /// The colour of the sound tracks' pieces.
+ public static readonly StyledProperty SoundBrushProperty =
+ AvaloniaProperty.Register(nameof(SoundBrush), new SolidColorBrush(Color.FromRgb(46, 160, 140)));
+
+ public static readonly StyledProperty MusicBrushProperty =
+ AvaloniaProperty.Register(nameof(MusicBrush), new SolidColorBrush(Color.FromRgb(214, 138, 54)));
+
+ public IReadOnlyList? Pieces { get => GetValue(PiecesProperty); set => SetValue(PiecesProperty, value); }
+ public double Playhead { get => GetValue(PlayheadProperty); set => SetValue(PlayheadProperty, value); }
+ public int SelectedIndex { get => GetValue(SelectedIndexProperty); set => SetValue(SelectedIndexProperty, value); }
+ public ClipLane SelectedLane { get => GetValue(SelectedLaneProperty); set => SetValue(SelectedLaneProperty, value); }
+ public IReadOnlyList? SelectedIndices { get => GetValue(SelectedIndicesProperty); set => SetValue(SelectedIndicesProperty, value); }
+ public IReadOnlyList? Thumbnails { get => GetValue(ThumbnailsProperty); set => SetValue(ThumbnailsProperty, value); }
+ public IReadOnlyList? Loudness { get => GetValue(LoudnessProperty); set => SetValue(LoudnessProperty, value); }
+ public double DetailStart { get => GetValue(DetailStartProperty); set => SetValue(DetailStartProperty, value); }
+ public double DetailEnd { get => GetValue(DetailEndProperty); set => SetValue(DetailEndProperty, value); }
+ public ClipMusic? Music { get => GetValue(MusicProperty); set => SetValue(MusicProperty, value); }
+ public IReadOnlyList? MusicLoudness { get => GetValue(MusicLoudnessProperty); set => SetValue(MusicLoudnessProperty, value); }
+ public bool FadeIn { get => GetValue(FadeInProperty); set => SetValue(FadeInProperty, value); }
+ public bool FadeOut { get => GetValue(FadeOutProperty); set => SetValue(FadeOutProperty, value); }
+ public bool Snap { get => GetValue(SnapProperty); set => SetValue(SnapProperty, value); }
+ public string? GapText { get => GetValue(GapTextProperty); set => SetValue(GapTextProperty, value); }
+ public string? NoMusicText { get => GetValue(NoMusicTextProperty); set => SetValue(NoMusicTextProperty, value); }
+ public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); }
+ public IBrush? OnAccentBrush { get => GetValue(OnAccentBrushProperty); set => SetValue(OnAccentBrushProperty, value); }
+ public IBrush? TrackBrush { get => GetValue(TrackBrushProperty); set => SetValue(TrackBrushProperty, value); }
+ public IBrush? StageBrush { get => GetValue(StageBrushProperty); set => SetValue(StageBrushProperty, value); }
+ public IBrush? Foreground { get => GetValue(ForegroundProperty); set => SetValue(ForegroundProperty, value); }
+ public IBrush? SoundBrush { get => GetValue(SoundBrushProperty); set => SetValue(SoundBrushProperty, value); }
+ public IBrush? MusicBrush { get => GetValue(MusicBrushProperty); set => SetValue(MusicBrushProperty, value); }
+
+ /// The playhead was put somewhere: seconds into the clip.
+ public event Action? PlayheadMoved;
+
+ /// A piece (or the music, -1) was clicked on a track: alone, with Ctrl or with Shift.
+ public event Action? PieceSelected;
+
+ /// Dragged across pieces: from and to (seconds into the clip), the track, and Ctrl (add to the selection).
+ public event Action? MarqueeSelected;
+
+ /// A piece's edge started to be dragged: the piece, and its end (true) or start.
+ public event Action? EdgeDragStarted;
+
+ /// The dragged edge's place in the video, in seconds.
+ public event Action? EdgeDragged;
+
+ /// A gap's button was clicked: the piece that left it.
+ public event Action? GapCloseRequested;
+
+ /// A piece's volume line was dragged: the piece and its volume (0 to 2).
+ public event Action? VolumeDragged;
+
+ /// The music was dragged: where in the clip it starts now.
+ public event Action? MusicMoved;
+
+ /// The music's volume line was dragged (0 to 2).
+ public event Action? MusicVolumeDragged;
+
+ // Layout: the tracks' names on the left, a ruler on top, then V1, A1 and A2.
+ internal const double LabelWidth = 30;
+ internal const double RulerHeight = 16;
+ internal const double VideoHeight = 54;
+ internal const double SoundHeight = 40;
+ internal const double MusicHeight = 32;
+ private const double LaneGap = 4;
+ private const double Radius = 6;
+ private const double EdgeGrab = 7;
+ private const double SnapDistance = 8;
+ private const double DragThreshold = 4;
+
+ private enum Drag { None, Playhead, Edge, Volume, Music, MusicVolume, Pending, Marquee }
+
+ // Made once: a new cursor on every pointer move would ask the system for one each time.
+ private static readonly Cursor IbeamCursor = new(StandardCursorType.Ibeam);
+ private static readonly Cursor SizeNorthSouthCursor = new(StandardCursorType.SizeNorthSouth);
+ private static readonly Cursor SizeAllCursor = new(StandardCursorType.SizeAll);
+ private static readonly Cursor SizeWestEastCursor = new(StandardCursorType.SizeWestEast);
+ private static readonly Cursor HandCursor = new(StandardCursorType.Hand);
+
+ // The pieces' places in the clip and its length, worked out once per change of the pieces (not on every x).
+ private IReadOnlyList<(int Index, double From, double To)>? _layout;
+ private double _total = ClipRange.MinLength;
+
+ private Drag _drag;
+ private Point _pressedAt;
+ private double _scaleAtPress; // pixels a second when the drag started (the sequence changes length as it goes)
+ private double _edgeAtPress; // the dragged edge's place in the video when the drag started
+ private double _speedAtPress;
+ private bool _dragEnd;
+ private int _pendingIndex = -1;
+ private ClipLane _pendingLane;
+ private double _musicOffsetAtPress;
+ private ClipSelect _pendingMode;
+ private bool _marqueeAdds;
+ private double _marqueeFrom, _marqueeTo;
+
+ static ClipSequence()
+ {
+ AffectsRender(PiecesProperty, PlayheadProperty, SelectedIndexProperty, SelectedIndicesProperty, SelectedLaneProperty, ThumbnailsProperty,
+ LoudnessProperty, DetailStartProperty, DetailEndProperty, MusicProperty, MusicLoudnessProperty, FadeInProperty, FadeOutProperty,
+ GapTextProperty, NoMusicTextProperty, AccentBrushProperty, TrackBrushProperty, StageBrushProperty, ForegroundProperty);
+ FocusableProperty.OverrideDefaultValue(false);
+ }
+
+ public ClipSequence()
+ {
+ FlowDirection = FlowDirection.LeftToRight;
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == PiecesProperty)
+ _layout = null;
+ }
+
+ protected override Size MeasureOverride(Size availableSize) =>
+ new(0, RulerHeight + VideoHeight + LaneGap + SoundHeight + LaneGap + MusicHeight);
+
+ internal Rect Content => new(LabelWidth + 6, 0, Math.Max(1, Bounds.Width - LabelWidth - 12), Bounds.Height);
+ internal Rect VideoLane => new(Content.X, RulerHeight, Content.Width, VideoHeight);
+ internal Rect SoundLane => new(Content.X, RulerHeight + VideoHeight + LaneGap, Content.Width, SoundHeight);
+ internal Rect MusicLane => new(Content.X, SoundLane.Bottom + LaneGap, Content.Width, MusicHeight);
+
+ /// How long the clip plays (at least a moment, so the scale is never infinite).
+ internal double Total
+ {
+ get
+ {
+ _ = Layout();
+ return _total;
+ }
+ }
+
+ internal double XOf(double seconds) => Content.X + seconds / Total * Content.Width;
+
+ internal double TimeAt(double x) => Math.Clamp((x - Content.X) / Content.Width, 0, 1) * Total;
+
+ /// Where each piece that takes time stands in the clip: its index and its seconds into the clip.
+ internal IReadOnlyList<(int Index, double From, double To)> Layout()
+ {
+ if (_layout is { } cached)
+ return cached;
+ var spans = new List<(int, double, double)>();
+ _layout = spans;
+ _total = ClipRange.MinLength;
+ if (Pieces is not { } pieces)
+ return spans;
+ var at = 0.0;
+ for (var i = 0; i < pieces.Count; i++)
+ {
+ var length = pieces[i].Length;
+ if (length <= 0)
+ continue;
+ spans.Add((i, at, at + length));
+ at += length;
+ }
+ _total = Math.Max(ClipRange.MinLength, at);
+ return spans;
+ }
+
+ // ───────────────────────── Drawing ─────────────────────────
+
+ public override void Render(DrawingContext context)
+ {
+ if (Bounds.Width < LabelWidth + 20 || Pieces is not { Count: > 0 } pieces)
+ return;
+ var accent = AccentBrush ?? Brushes.MediumPurple;
+ DrawLabels(context);
+ DrawRuler(context);
+ foreach (var lane in new[] { VideoLane, SoundLane, MusicLane })
+ context.DrawRectangle(TrackBrush ?? Brushes.LightGray, null, lane, Radius, Radius);
+
+ foreach (var (index, from, to) in Layout())
+ {
+ var piece = pieces[index];
+ var video = new Rect(XOf(from), VideoLane.Y, Math.Max(1, XOf(to) - XOf(from)), VideoLane.Height);
+ var sound = new Rect(video.X, SoundLane.Y, video.Width, SoundLane.Height);
+ if (piece.State == ClipPieceState.Blank)
+ {
+ DrawGap(context, video, sound, accent);
+ continue;
+ }
+ DrawPictures(context, piece, video);
+ DrawSound(context, piece, sound);
+ if (piece.Speed != 1 || piece.Volume != 1 || piece.Muted)
+ DrawBadge(context, piece, video);
+ var selected = IsSelected(index);
+ var selectedVideo = selected && SelectedLane == ClipLane.Video;
+ var selectedSound = selected && SelectedLane == ClipLane.Audio;
+ context.DrawRectangle(null, new Pen(selectedVideo ? accent : new SolidColorBrush(Color.FromArgb(90, 0, 0, 0)), selectedVideo ? 2.5 : 1),
+ video.Deflate(selectedVideo ? 1.25 : 0.5), Radius, Radius);
+ context.DrawRectangle(null, new Pen(selectedSound ? accent : new SolidColorBrush(Color.FromArgb(90, 0, 0, 0)), selectedSound ? 2.5 : 1),
+ sound.Deflate(selectedSound ? 1.25 : 0.5), Radius, Radius);
+ }
+ DrawFades(context);
+ DrawMusic(context, accent);
+ DrawPlayhead(context);
+ if (_drag == Drag.Marquee)
+ {
+ // The stretch being dragged across: the pieces it touches become selected.
+ var rect = new Rect(XOf(Math.Min(_marqueeFrom, _marqueeTo)), VideoLane.Top - 2,
+ Math.Abs(XOf(_marqueeTo) - XOf(_marqueeFrom)), SoundLane.Bottom - VideoLane.Top + 4);
+ using (context.PushOpacity(0.18))
+ context.FillRectangle(accent, rect);
+ context.DrawRectangle(null, new Pen(accent, 1.2) { DashStyle = new DashStyle([3, 2], 0) }, rect);
+ }
+ }
+
+ /// True for a selected piece: one of , or without them.
+ private bool IsSelected(int index) =>
+ SelectedIndices is { Count: > 0 } several ? several.Contains(index) : index == SelectedIndex;
+
+ private void DrawLabels(DrawingContext context)
+ {
+ var brush = Foreground ?? Brushes.Black;
+ void Label(string text, Rect lane)
+ {
+ var formatted = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, Typeface.Default, 11, brush);
+ using (context.PushOpacity(0.6))
+ context.DrawText(formatted, new Point(4, lane.Center.Y - formatted.Height / 2));
+ }
+ Label("V1", VideoLane);
+ Label("A1", SoundLane);
+ Label("A2", MusicLane);
+ }
+
+ private void DrawRuler(DrawingContext context)
+ {
+ var pen = new Pen(Foreground ?? Brushes.Gray, 1);
+ double[] steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
+ var step = steps.FirstOrDefault(s => s / Total * Content.Width >= 8, steps[^1]);
+ using (context.PushOpacity(0.45))
+ {
+ for (var n = 0; n * step <= Total + 1e-6; n++)
+ {
+ var x = Math.Round(XOf(n * step)) + 0.5;
+ var tall = n % 5 == 0;
+ context.DrawLine(pen, new Point(x, RulerHeight - (tall ? 8 : 4)), new Point(x, RulerHeight - 1));
+ }
+ }
+ }
+
+ /// The pictures of the piece's stretch of the video, side by side.
+ private void DrawPictures(DrawingContext context, ClipPiece piece, Rect rect)
+ {
+ using var clip = context.PushClip(new RoundedRect(rect, Radius));
+ context.FillRectangle(TrackBrush ?? Brushes.Gray, rect);
+ if (Thumbnails is not { Count: > 0 } pictures || DetailEnd <= DetailStart)
+ return;
+ var span = (DetailEnd - DetailStart) / pictures.Count;
+ // Picture k stands for [DetailStart + k·span, + span) of the video: drawn where that time plays in the piece.
+ for (var k = 0; k < pictures.Count; k++)
+ {
+ if (pictures[k] is not { } bitmap)
+ continue;
+ var from = DetailStart + k * span;
+ var to = from + span;
+ if (to <= piece.Start || from >= piece.End)
+ continue;
+ var left = rect.X + (Math.Max(from, piece.Start) - piece.Start) / (piece.End - piece.Start) * rect.Width;
+ var right = rect.X + (Math.Min(to, piece.End) - piece.Start) / (piece.End - piece.Start) * rect.Width;
+ var target = new Rect(left, rect.Y, Math.Max(1, right - left + 0.5), rect.Height);
+ var size = bitmap.Size;
+ var scale = Math.Max(target.Width / size.Width, target.Height / size.Height);
+ var w = target.Width / scale;
+ var h = target.Height / scale;
+ context.DrawImage(bitmap, new Rect((size.Width - w) / 2, (size.Height - h) / 2, w, h), target);
+ }
+ }
+
+ /// The piece's sound: its loudness (as loud as its volume makes it) and its volume line; hatched when muted.
+ private void DrawSound(DrawingContext context, ClipPiece piece, Rect rect)
+ {
+ var sound = SoundBrush ?? Brushes.Teal;
+ using var clip = context.PushClip(new RoundedRect(rect, Radius));
+ using (context.PushOpacity(piece.Muted ? 0.18 : 0.35))
+ context.FillRectangle(sound, rect);
+ if (piece.Muted)
+ {
+ using (context.PushOpacity(0.35))
+ {
+ var hatch = new Pen(Foreground ?? Brushes.Black, 1);
+ for (var x = rect.Left - rect.Height; x < rect.Right; x += 8)
+ context.DrawLine(hatch, new Point(x, rect.Bottom), new Point(x + rect.Height, rect.Top));
+ }
+ return;
+ }
+ if (Loudness is { Count: > 0 } levels && DetailEnd > DetailStart)
+ {
+ var bars = new StreamGeometry();
+ using (var g = bars.Open())
+ {
+ var step = (DetailEnd - DetailStart) / levels.Count;
+ var first = Math.Max(0, (int)((piece.Start - DetailStart) / step));
+ var last = Math.Min(levels.Count - 1, (int)Math.Ceiling((piece.End - DetailStart) / step));
+ for (var i = first; i <= last; i++)
+ {
+ var t0 = Math.Max(piece.Start, DetailStart + i * step);
+ var t1 = Math.Min(piece.End, DetailStart + (i + 1) * step);
+ if (t1 <= t0)
+ continue;
+ var x0 = rect.X + (t0 - piece.Start) / (piece.End - piece.Start) * rect.Width;
+ var x1 = rect.X + (t1 - piece.Start) / (piece.End - piece.Start) * rect.Width;
+ var height = Math.Min(rect.Height - 4, Math.Max(0.5, levels[i] * Math.Min(piece.Volume, 2) * (rect.Height - 4) / 2));
+ var middle = rect.Center.Y;
+ g.BeginFigure(new Point(x0, middle - height / 2), true);
+ g.LineTo(new Point(Math.Max(x0 + 0.6, x1 - 0.4), middle - height / 2));
+ g.LineTo(new Point(Math.Max(x0 + 0.6, x1 - 0.4), middle + height / 2));
+ g.LineTo(new Point(x0, middle + height / 2));
+ g.EndFigure(true);
+ }
+ }
+ context.DrawGeometry(sound, null, bars);
+ }
+ // The volume line: 100 % in the middle, 200 % at the top, silent at the bottom.
+ var y = VolumeY(rect, piece.Volume);
+ context.DrawLine(new Pen(Brushes.White, 3), new Point(rect.Left, y), new Point(rect.Right, y));
+ context.DrawLine(new Pen(Foreground ?? Brushes.Black, 1.2), new Point(rect.Left, y), new Point(rect.Right, y));
+ }
+
+ private static double VolumeY(Rect rect, double volume) => rect.Bottom - 3 - Math.Clamp(volume, 0, 2) / 2 * (rect.Height - 6);
+
+ private static double VolumeAt(Rect rect, double y) => Math.Clamp((rect.Bottom - 3 - y) / (rect.Height - 6) * 2, 0, 2);
+
+ /// A gap a piece cut out left: dashed, hatched, with the button that closes it.
+ private void DrawGap(DrawingContext context, Rect video, Rect sound, IBrush accent)
+ {
+ var rect = new Rect(video.X, video.Y, video.Width, sound.Bottom - video.Y);
+ var dash = new Pen(accent, 1.5) { DashStyle = new DashStyle([4, 3], 0) };
+ context.DrawRectangle(null, dash, rect.Deflate(1), Radius, Radius);
+ using (context.PushClip(rect))
+ using (context.PushOpacity(0.25))
+ {
+ var hatch = new Pen(accent, 1);
+ for (var x = rect.Left - rect.Height; x < rect.Right; x += 9)
+ context.DrawLine(hatch, new Point(x, rect.Bottom), new Point(x + rect.Height, rect.Top));
+ }
+ var chip = GapButton(rect);
+ if (chip is null)
+ return;
+ context.DrawRectangle(accent, null, chip.Value, 999, 999);
+ var text = new FormattedText(chip.Value.Width > 40 ? "✕ " + GapText : "✕", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ Typeface.Default, 11, OnAccentBrush ?? Brushes.White);
+ context.DrawText(text, new Point(chip.Value.Center.X - text.Width / 2, chip.Value.Center.Y - text.Height / 2));
+ }
+
+ /// Where a gap's button is: centred, with its words when there is room, just ✕ when the gap is narrow.
+ private Rect? GapButton(Rect gap)
+ {
+ if (gap.Width < 18)
+ return null;
+ var text = new FormattedText("✕ " + GapText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 11, Brushes.White);
+ var width = text.Width + 16 <= gap.Width - 8 ? text.Width + 16 : 20;
+ return new Rect(gap.Center.X - width / 2, gap.Center.Y - 10, width, 20);
+ }
+
+ private void DrawBadge(DrawingContext context, ClipPiece piece, Rect rect)
+ {
+ var parts = new List();
+ if (piece.Speed != 1)
+ parts.Add("×" + piece.Speed.ToString("0.##", CultureInfo.CurrentCulture));
+ if (piece.Muted)
+ parts.Add("A1 ✕");
+ else if (piece.Volume != 1)
+ parts.Add(Math.Round(piece.Volume * 100).ToString(CultureInfo.CurrentCulture) + "%");
+ var text = new FormattedText(string.Join(" · ", parts), CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 10.5, Brushes.White);
+ var box = new Rect(rect.Left + 5, rect.Top + 5, text.Width + 10, text.Height + 2);
+ if (box.Right > rect.Right - 3)
+ return;
+ using (context.PushOpacity(0.72))
+ context.DrawRectangle(Brushes.Black, null, box, 5, 5);
+ context.DrawText(text, new Point(box.Left + 5, box.Top + 1));
+ }
+
+ private void DrawFades(DrawingContext context)
+ {
+ if (!FadeIn && !FadeOut)
+ return;
+ var width = Math.Max(8, Math.Min(XOf(ClipWindowViewModel.FadeSeconds) - XOf(0), Content.Width / 2));
+ var shade = new SolidColorBrush(Color.FromArgb(110, 0, 0, 0));
+ var line = new Pen(Brushes.White, 1.5);
+ void Ramp(double from, double to)
+ {
+ foreach (var lane in new[] { VideoLane, SoundLane })
+ {
+ var geometry = new StreamGeometry();
+ using (var g = geometry.Open())
+ {
+ g.BeginFigure(new Point(from, lane.Top), true);
+ g.LineTo(new Point(to, lane.Top));
+ g.LineTo(new Point(from, lane.Bottom));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(shade, null, geometry);
+ context.DrawLine(line, new Point(from, lane.Bottom), new Point(to, lane.Top));
+ }
+ }
+ if (FadeIn)
+ Ramp(Content.Left, Content.Left + width);
+ if (FadeOut)
+ Ramp(Content.Right, Content.Right - width);
+ }
+
+ /// Where the music stands on A2, or null without music.
+ internal Rect? MusicRect()
+ {
+ if (Music is not { } music)
+ return null;
+ var length = music.PlayLength(Total);
+ if (length <= 0)
+ return null;
+ var left = XOf(music.Offset);
+ return new Rect(left, MusicLane.Y, Math.Max(4, XOf(music.Offset + length) - left), MusicLane.Height);
+ }
+
+ private void DrawMusic(DrawingContext context, IBrush accent)
+ {
+ var lane = MusicLane;
+ if (MusicRect() is not { } rect || Music is not { } music)
+ {
+ if (!string.IsNullOrEmpty(NoMusicText))
+ {
+ var hint = new FormattedText(NoMusicText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 11,
+ Foreground ?? Brushes.Gray);
+ using (context.PushOpacity(0.5))
+ context.DrawText(hint, new Point(lane.X + 10, lane.Center.Y - hint.Height / 2));
+ }
+ return;
+ }
+ var brush = MusicBrush ?? Brushes.Orange;
+ using (context.PushClip(new RoundedRect(rect, Radius)))
+ {
+ using (context.PushOpacity(0.35))
+ context.FillRectangle(brush, rect);
+ if (MusicLoudness is { Count: > 0 } levels && music.Length > 0)
+ {
+ var length = music.PlayLength(Total);
+ var bars = new StreamGeometry();
+ using (var g = bars.Open())
+ {
+ for (var i = 0; i < levels.Count; i++)
+ {
+ var t = music.Length * i / levels.Count - music.From; // seconds into its play
+ var t1 = music.Length * (i + 1) / levels.Count - music.From;
+ if (t1 <= 0 || t >= length)
+ continue;
+ var x0 = XOf(music.Offset + Math.Max(0, t));
+ var x1 = XOf(music.Offset + Math.Min(length, t1));
+ var height = Math.Max(0.5, levels[i] * Math.Min(music.Volume, 2) * (rect.Height - 4) / 2);
+ g.BeginFigure(new Point(x0, rect.Center.Y - height / 2), true);
+ g.LineTo(new Point(Math.Max(x0 + 0.6, x1 - 0.3), rect.Center.Y - height / 2));
+ g.LineTo(new Point(Math.Max(x0 + 0.6, x1 - 0.3), rect.Center.Y + height / 2));
+ g.LineTo(new Point(x0, rect.Center.Y + height / 2));
+ g.EndFigure(true);
+ }
+ }
+ context.DrawGeometry(brush, null, bars);
+ }
+ var name = new FormattedText(System.IO.Path.GetFileName(music.Path), CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Typeface.Default, 10.5,
+ Foreground ?? Brushes.Black);
+ context.DrawText(name, new Point(rect.Left + 6, rect.Top + 1));
+ var y = VolumeY(rect, music.Volume);
+ context.DrawLine(new Pen(Brushes.White, 3), new Point(rect.Left, y), new Point(rect.Right, y));
+ context.DrawLine(new Pen(Foreground ?? Brushes.Black, 1.2), new Point(rect.Left, y), new Point(rect.Right, y));
+ }
+ var selected = SelectedLane == ClipLane.Music;
+ context.DrawRectangle(null, new Pen(selected ? accent : brush, selected ? 2.5 : 1), rect.Deflate(selected ? 1.25 : 0.5), Radius, Radius);
+ }
+
+ private void DrawPlayhead(DrawingContext context)
+ {
+ var x = Math.Round(XOf(Math.Clamp(Playhead, 0, Total))) + 0.5;
+ var color = Foreground ?? Brushes.Black;
+ context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(150, 255, 255, 255)), 4), new Point(x, RulerHeight), new Point(x, MusicLane.Bottom));
+ context.DrawLine(new Pen(color, 1.5), new Point(x, 2), new Point(x, MusicLane.Bottom));
+ var knob = new StreamGeometry();
+ using (var g = knob.Open())
+ {
+ g.BeginFigure(new Point(x - 6, 1), true);
+ g.LineTo(new Point(x + 6, 1));
+ g.LineTo(new Point(x + 6, 8));
+ g.LineTo(new Point(x, RulerHeight - 1));
+ g.LineTo(new Point(x - 6, 8));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(AccentBrush ?? color, null, knob);
+ }
+
+ // ───────────────────────── Pointer ─────────────────────────
+
+ /// into the clip, or with the magnet the cut or playhead within reach.
+ private double SnapTime(double seconds, bool playhead)
+ {
+ if (!Snap)
+ return seconds;
+ var points = Layout().SelectMany(s => new[] { s.From, s.To }).ToList();
+ if (!playhead)
+ points.Add(Playhead);
+ var snapped = seconds;
+ var nearest = SnapDistance;
+ foreach (var point in points)
+ {
+ var distance = Math.Abs(XOf(point) - XOf(seconds));
+ if (distance <= nearest)
+ {
+ nearest = distance;
+ snapped = point;
+ }
+ }
+ return snapped;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (Pieces is not { Count: > 0 } pieces || !IsEnabled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ var point = e.GetPosition(this);
+ _pressedAt = point;
+ _scaleAtPress = Content.Width / Total;
+ _drag = Drag.None;
+
+ if (point.Y < RulerHeight)
+ {
+ _drag = Drag.Playhead;
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(point.X), playhead: true));
+ }
+ else if (point.Y >= MusicLane.Top - LaneGap / 2)
+ {
+ if (MusicRect() is { } music && music.Inflate(new Thickness(0, 2)).Contains(point))
+ {
+ PieceSelected?.Invoke(-1, ClipLane.Music, ClipSelect.Only);
+ if (Math.Abs(point.Y - VolumeY(music, Music!.Volume)) <= 5)
+ _drag = Drag.MusicVolume;
+ else
+ {
+ _drag = Drag.Music;
+ _musicOffsetAtPress = Music.Offset;
+ }
+ }
+ else
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(point.X), playhead: true));
+ }
+ else
+ {
+ var lane = point.Y >= SoundLane.Top - LaneGap / 2 ? ClipLane.Audio : ClipLane.Video;
+ foreach (var (index, from, to) in Layout())
+ {
+ var left = XOf(from);
+ var right = XOf(to);
+ if (point.X < left - EdgeGrab || point.X > right + EdgeGrab)
+ continue;
+ var piece = pieces[index];
+ if (piece.State == ClipPieceState.Blank)
+ {
+ var gap = new Rect(left, VideoLane.Y, right - left, SoundLane.Bottom - VideoLane.Y);
+ if (GapButton(gap) is { } button && button.Inflate(3).Contains(point))
+ {
+ GapCloseRequested?.Invoke(index);
+ e.Handled = true;
+ return;
+ }
+ if (point.X < left || point.X > right)
+ continue;
+ }
+ else if (Math.Abs(point.X - left) <= EdgeGrab || Math.Abs(point.X - right) <= EdgeGrab)
+ {
+ // An edge: trimmed by dragging.
+ _dragEnd = Math.Abs(point.X - right) <= Math.Abs(point.X - left);
+ _edgeAtPress = _dragEnd ? piece.End : piece.Start;
+ _speedAtPress = piece.Speed;
+ _drag = Drag.Edge;
+ PieceSelected?.Invoke(index, lane, ClipSelect.Only);
+ EdgeDragStarted?.Invoke(index, _dragEnd);
+ break;
+ }
+ if (point.X < left || point.X > right)
+ continue;
+ if (lane == ClipLane.Audio && !piece.Muted && Math.Abs(point.Y - VolumeY(new Rect(left, SoundLane.Y, right - left, SoundLane.Height), piece.Volume)) <= 5)
+ {
+ _drag = Drag.Volume;
+ _pendingIndex = index;
+ PieceSelected?.Invoke(index, lane, ClipSelect.Only);
+ break;
+ }
+ // A click selects the piece (on release); a drag selects every piece it goes across.
+ _drag = Drag.Pending;
+ _pendingIndex = index;
+ _pendingLane = lane;
+ _pendingMode = (e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Meta)) != 0 ? ClipSelect.Toggle
+ : (e.KeyModifiers & KeyModifiers.Shift) != 0 ? ClipSelect.Range
+ : ClipSelect.Only;
+ break;
+ }
+ if (_drag == Drag.None)
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(point.X), playhead: true));
+ }
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ var point = e.GetPosition(this);
+ if (_drag == Drag.None)
+ {
+ Cursor = CursorAt(point);
+ return;
+ }
+ var dx = point.X - _pressedAt.X;
+ switch (_drag)
+ {
+ case Drag.Playhead:
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(point.X), playhead: true));
+ break;
+ case Drag.Edge:
+ // The edge follows the pointer at the scale the drag started with.
+ var seconds = dx / _scaleAtPress * _speedAtPress;
+ EdgeDragged?.Invoke(_edgeAtPress + seconds);
+ break;
+ case Drag.Volume when _pendingIndex >= 0:
+ VolumeDragged?.Invoke(_pendingIndex, VolumeAt(SoundLane, point.Y));
+ break;
+ case Drag.Music:
+ var offset = _musicOffsetAtPress + dx / _scaleAtPress;
+ MusicMoved?.Invoke(Math.Max(0, SnapTime(offset, playhead: false)));
+ break;
+ case Drag.MusicVolume:
+ MusicVolumeDragged?.Invoke(VolumeAt(MusicLane, point.Y));
+ break;
+ case Drag.Pending when Math.Abs(dx) >= DragThreshold:
+ _drag = Drag.Marquee;
+ _marqueeFrom = TimeAt(_pressedAt.X);
+ _marqueeAdds = _pendingMode != ClipSelect.Only;
+ goto case Drag.Marquee;
+ case Drag.Marquee:
+ _marqueeTo = TimeAt(point.X);
+ MarqueeSelected?.Invoke(_marqueeFrom, _marqueeTo, _pendingLane, _marqueeAdds);
+ InvalidateVisual();
+ break;
+ }
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (_drag == Drag.Pending)
+ {
+ PieceSelected?.Invoke(_pendingIndex, _pendingLane, _pendingMode);
+ // Ctrl and Shift only change the selection; a plain click also puts the playhead there.
+ if (_pendingMode == ClipSelect.Only)
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(e.GetPosition(this).X), playhead: true));
+ }
+ var marquee = _drag == Drag.Marquee;
+ _drag = Drag.None;
+ if (marquee)
+ InvalidateVisual();
+ _pendingIndex = -1;
+ e.Pointer.Capture(null);
+ }
+
+ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
+ {
+ base.OnPointerCaptureLost(e);
+ _drag = Drag.None;
+ }
+
+ private Cursor CursorAt(Point point)
+ {
+ if (point.Y < RulerHeight)
+ return IbeamCursor;
+ if (point.Y >= MusicLane.Top - LaneGap / 2)
+ return MusicRect() is { } music && music.Contains(point)
+ ? Math.Abs(point.Y - VolumeY(music, Music!.Volume)) <= 5 ? SizeNorthSouthCursor : SizeAllCursor
+ : Cursor.Default;
+ if (Pieces is not { } pieces)
+ return Cursor.Default;
+ foreach (var (index, from, to) in Layout())
+ {
+ var left = XOf(from);
+ var right = XOf(to);
+ if (pieces[index].State != ClipPieceState.Blank && (Math.Abs(point.X - left) <= EdgeGrab || Math.Abs(point.X - right) <= EdgeGrab))
+ return SizeWestEastCursor;
+ if (point.X >= left && point.X <= right && point.Y >= SoundLane.Top && !pieces[index].Muted && pieces[index].IsKept
+ && Math.Abs(point.Y - VolumeY(new Rect(left, SoundLane.Y, right - left, SoundLane.Height), pieces[index].Volume)) <= 5)
+ return SizeNorthSouthCursor;
+ }
+ return HandCursor;
+ }
+}
diff --git a/src/Filee.App/Controls/ClipTimeline.cs b/src/Filee.App/Controls/ClipTimeline.cs
new file mode 100644
index 000000000..2a4c7c7c9
--- /dev/null
+++ b/src/Filee.App/Controls/ClipTimeline.cs
@@ -0,0 +1,643 @@
+// The clip window's timeline: a ruler for the playhead over a strip of small pictures of the video with the loudness
+// of its sound, the part outside the clip dimmed and the clip framed in the accent colour between two handles. Inside
+// the clip its pieces show: cuts as thin lines, pieces cut out hatched (dark when they leave a pause), the selected
+// piece outlined, a changed volume or speed written on it, fades as ramps at the ends.
+// Dragging a handle moves that end, dragging inside the clip moves all of it, a click (or the ruler) puts the playhead
+// there. With the magnet on, the handles and the playhead snap to cuts, the clip's ends and each other. Time always
+// runs left to right, also in right-to-left languages.
+
+using System.Collections.Specialized;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Filee.App.ViewModels;
+using Filee.Core.Presets;
+
+namespace Filee.App.Controls;
+
+public sealed class ClipTimeline : Control
+{
+ public static readonly StyledProperty DurationProperty =
+ AvaloniaProperty.Register(nameof(Duration));
+
+ public static readonly StyledProperty StartProperty =
+ AvaloniaProperty.Register(nameof(Start));
+
+ public static readonly StyledProperty EndProperty =
+ AvaloniaProperty.Register(nameof(End));
+
+ public static readonly StyledProperty PlayheadProperty =
+ AvaloniaProperty.Register(nameof(Playhead));
+
+ public static readonly StyledProperty ActiveHandleProperty =
+ AvaloniaProperty.Register(nameof(ActiveHandle));
+
+ public static readonly StyledProperty?> PiecesProperty =
+ AvaloniaProperty.Register?>(nameof(Pieces));
+
+ public static readonly StyledProperty SelectedIndexProperty =
+ AvaloniaProperty.Register(nameof(SelectedIndex), -1);
+
+ public static readonly StyledProperty?> LoudnessProperty =
+ AvaloniaProperty.Register?>(nameof(Loudness));
+
+ public static readonly StyledProperty FadeInProperty =
+ AvaloniaProperty.Register(nameof(FadeIn));
+
+ public static readonly StyledProperty FadeOutProperty =
+ AvaloniaProperty.Register(nameof(FadeOut));
+
+ /// Snap the handles and the playhead to cuts, the clip's ends and each other (the magnet).
+ public static readonly StyledProperty SnapProperty =
+ AvaloniaProperty.Register(nameof(Snap), true);
+
+ ///
+ /// The stretch of the video shown, in seconds (zoomed in on the clip); the whole video when
+ /// isn't after .
+ ///
+ public static readonly StyledProperty ViewStartProperty =
+ AvaloniaProperty.Register(nameof(ViewStart));
+
+ public static readonly StyledProperty ViewEndProperty =
+ AvaloniaProperty.Register(nameof(ViewEnd));
+
+ public static readonly StyledProperty?> ThumbnailsProperty =
+ AvaloniaProperty.Register?>(nameof(Thumbnails));
+
+ public static readonly StyledProperty AccentBrushProperty =
+ AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple);
+
+ /// The colour on the handles' grips (on the accent).
+ public static readonly StyledProperty OnAccentBrushProperty =
+ AvaloniaProperty.Register(nameof(OnAccentBrush), Brushes.White);
+
+ /// Behind the pictures, until they are there.
+ public static readonly StyledProperty TrackBrushProperty =
+ AvaloniaProperty.Register(nameof(TrackBrush), Brushes.LightGray);
+
+ /// Laid over the video outside the clip, mostly opaque (the colour of the card the timeline is on).
+ public static readonly StyledProperty StageBrushProperty =
+ AvaloniaProperty.Register(nameof(StageBrush), Brushes.White);
+
+ /// The ruler's marks and the playhead.
+ public static readonly StyledProperty ForegroundProperty =
+ AvaloniaProperty.Register(nameof(Foreground), Brushes.Black);
+
+ public double Duration { get => GetValue(DurationProperty); set => SetValue(DurationProperty, value); }
+ public double Start { get => GetValue(StartProperty); set => SetValue(StartProperty, value); }
+ public double End { get => GetValue(EndProperty); set => SetValue(EndProperty, value); }
+ public double Playhead { get => GetValue(PlayheadProperty); set => SetValue(PlayheadProperty, value); }
+ public ClipHandle ActiveHandle { get => GetValue(ActiveHandleProperty); set => SetValue(ActiveHandleProperty, value); }
+ public IReadOnlyList? Pieces { get => GetValue(PiecesProperty); set => SetValue(PiecesProperty, value); }
+ public int SelectedIndex { get => GetValue(SelectedIndexProperty); set => SetValue(SelectedIndexProperty, value); }
+ public IReadOnlyList? Loudness { get => GetValue(LoudnessProperty); set => SetValue(LoudnessProperty, value); }
+ public bool FadeIn { get => GetValue(FadeInProperty); set => SetValue(FadeInProperty, value); }
+ public bool FadeOut { get => GetValue(FadeOutProperty); set => SetValue(FadeOutProperty, value); }
+ public bool Snap { get => GetValue(SnapProperty); set => SetValue(SnapProperty, value); }
+ public double ViewStart { get => GetValue(ViewStartProperty); set => SetValue(ViewStartProperty, value); }
+ public double ViewEnd { get => GetValue(ViewEndProperty); set => SetValue(ViewEndProperty, value); }
+ public IReadOnlyList? Thumbnails { get => GetValue(ThumbnailsProperty); set => SetValue(ThumbnailsProperty, value); }
+ public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); }
+ public IBrush? OnAccentBrush { get => GetValue(OnAccentBrushProperty); set => SetValue(OnAccentBrushProperty, value); }
+ public IBrush? TrackBrush { get => GetValue(TrackBrushProperty); set => SetValue(TrackBrushProperty, value); }
+ public IBrush? StageBrush { get => GetValue(StageBrushProperty); set => SetValue(StageBrushProperty, value); }
+ public IBrush? Foreground { get => GetValue(ForegroundProperty); set => SetValue(ForegroundProperty, value); }
+
+ /// A handle was dragged (or the strip pressed beside the clip): the end it moves and where to.
+ public event Action? HandleMoved;
+
+ /// The clip was dragged by its middle: where it starts now.
+ public event Action? ClipMoved;
+
+ /// The playhead was put somewhere: a click in the clip, or the ruler.
+ public event Action? PlayheadMoved;
+
+ /// Width of a handle, outside the clip on either side.
+ internal const double HandleWidth = 14;
+
+ /// Height of the ruler above the pictures, where the playhead's knob is.
+ internal const double RulerHeight = 16;
+
+ private const double StripRadius = 9;
+ private const double FrameThickness = 3;
+ private const double DimOpacity = 0.68;
+
+ /// How far beside a handle a press still takes it (thin clips, touch).
+ private const double HandleSlop = 8;
+
+ /// How far a drag moves before it moves the clip (less is a click: the playhead).
+ private const double DragThreshold = 4;
+
+ /// How near (in pixels) a cut or end has to be to snap to it.
+ private const double SnapDistance = 8;
+
+ private enum Drag { None, Start, End, Clip, Playhead, Pending }
+
+ // Made once: a new cursor on every pointer move would ask the system for one each time.
+ private static readonly Cursor HandCursor = new(StandardCursorType.Hand);
+ private static readonly Cursor IbeamCursor = new(StandardCursorType.Ibeam);
+ private static readonly Cursor SizeWestEastCursor = new(StandardCursorType.SizeWestEast);
+
+ private Drag _drag;
+ private double _grabOffset; // seconds between the pointer and what it drags (the handle, or the clip's start)
+ private Point _pressedAt;
+ private INotifyCollectionChanged? _watched;
+
+ static ClipTimeline()
+ {
+ AffectsRender(DurationProperty, StartProperty, EndProperty, PlayheadProperty, ActiveHandleProperty, PiecesProperty,
+ SelectedIndexProperty, LoudnessProperty, FadeInProperty, FadeOutProperty, ViewStartProperty, ViewEndProperty, ThumbnailsProperty, AccentBrushProperty,
+ OnAccentBrushProperty, TrackBrushProperty, StageBrushProperty, ForegroundProperty);
+ FocusableProperty.OverrideDefaultValue(false);
+ }
+
+ public ClipTimeline()
+ {
+ FlowDirection = FlowDirection.LeftToRight;
+ ClipToBounds = false;
+ }
+
+ /// The strip of pictures: below the ruler, room for the handles at its sides.
+ internal Rect Strip => new(HandleWidth, RulerHeight, Math.Max(1, Bounds.Width - 2 * HandleWidth), Math.Max(1, Bounds.Height - RulerHeight));
+
+ /// The stretch of the video shown: the view, or all of it.
+ private (double From, double To) View =>
+ ViewEnd > ViewStart && Duration > 0
+ ? (Math.Clamp(ViewStart, 0, Duration), Math.Clamp(ViewEnd, ViewStart, Duration))
+ : (0, Math.Max(Duration, 1e-9));
+
+ /// The x of on the strip (at its edge when outside the view).
+ internal double XOf(double seconds) => Math.Clamp(RawX(seconds), Strip.Left, Strip.Right);
+
+ /// The x of , also beyond the strip.
+ private double RawX(double seconds)
+ {
+ var (from, to) = View;
+ return Strip.X + (Duration > 0 ? (seconds - from) / Math.Max(1e-9, to - from) : 0) * Strip.Width;
+ }
+
+ /// The time at on the strip.
+ internal double TimeAt(double x)
+ {
+ if (Duration <= 0)
+ return 0;
+ var (from, to) = View;
+ return from + Math.Clamp((x - Strip.X) / Strip.Width, 0, 1) * (to - from);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == ThumbnailsProperty)
+ {
+ if (_watched is not null)
+ _watched.CollectionChanged -= OnThumbnailsChanged;
+ _watched = change.GetNewValue?>() as INotifyCollectionChanged;
+ if (_watched is not null)
+ _watched.CollectionChanged += OnThumbnailsChanged;
+ }
+ }
+
+ private void OnThumbnailsChanged(object? sender, NotifyCollectionChangedEventArgs e) => InvalidateVisual();
+
+ // ───────────────────────── Drawing ─────────────────────────
+
+ public override void Render(DrawingContext context)
+ {
+ var strip = Strip;
+ if (strip.Width <= 2 || strip.Height <= 2)
+ return;
+ var accent = AccentBrush ?? Brushes.MediumPurple;
+ DrawRuler(context);
+
+ using (context.PushClip(new RoundedRect(strip, StripRadius)))
+ {
+ context.FillRectangle(TrackBrush ?? Brushes.LightGray, strip);
+ DrawPictures(context, strip);
+ DrawLoudness(context, strip);
+ if (Duration > 0)
+ {
+ var dim = StageBrush ?? Brushes.White;
+ var x0 = XOf(Start);
+ var x1 = XOf(End);
+ using (context.PushOpacity(DimOpacity))
+ {
+ if (x0 > strip.Left)
+ context.FillRectangle(dim, new Rect(strip.Left, strip.Top, x0 - strip.Left, strip.Height));
+ if (x1 < strip.Right)
+ context.FillRectangle(dim, new Rect(x1, strip.Top, strip.Right - x1, strip.Height));
+ }
+ DrawPieces(context, strip, accent);
+ DrawFades(context, strip);
+ }
+ }
+
+ if (Duration <= 0)
+ return;
+ DrawClipFrame(context, strip, accent);
+ DrawPlayhead(context, strip);
+ }
+
+ /// Ticks along the top: a short one every step, a taller one every fifth.
+ private void DrawRuler(DrawingContext context)
+ {
+ if (Duration <= 0)
+ return;
+ var strip = Strip;
+ var pen = new Pen(Foreground ?? Brushes.Gray, 1);
+ // A step that leaves at least 8 pixels between ticks: 1, 2, 5, 10, 15, 30 s, 1, 2, 5, 10 min.
+ double[] steps = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200];
+ var (from, to) = View;
+ double[] fine = [0.1, 0.2, 0.5];
+ var step = fine.Concat(steps).FirstOrDefault(s => s / (to - from) * strip.Width >= 8, steps[^1]);
+ using (context.PushOpacity(0.45))
+ {
+ for (var n = (int)Math.Ceiling(from / step - 1e-6); n * step <= to + 1e-6; n++)
+ {
+ var x = Math.Round(XOf(n * step)) + 0.5;
+ var tall = n % 5 == 0;
+ context.DrawLine(pen, new Point(x, RulerHeight - (tall ? 8 : 4)), new Point(x, RulerHeight - 1));
+ }
+ }
+ }
+
+ /// Each picture fills its part of the strip (cut to fit, from the middle).
+ private void DrawPictures(DrawingContext context, Rect strip)
+ {
+ if (Thumbnails is not { Count: > 0 } pictures)
+ return;
+ for (var i = 0; i < pictures.Count; i++)
+ {
+ if (pictures[i] is not { } bitmap)
+ continue;
+ // Each picture stands for its share of the video, wherever the view puts it.
+ var left = RawX(Duration * i / pictures.Count);
+ var right = RawX(Duration * (i + 1) / pictures.Count);
+ if (right < strip.Left || left > strip.Right)
+ continue;
+ var target = new Rect(left, strip.Y, right - left + 0.5, strip.Height); // overlap: no seams
+ var size = bitmap.Size;
+ if (size.Width <= 0 || size.Height <= 0)
+ continue;
+ var scale = Math.Max(target.Width / size.Width, target.Height / size.Height);
+ var w = target.Width / scale;
+ var h = target.Height / scale;
+ var source = new Rect((size.Width - w) / 2, (size.Height - h) / 2, w, h);
+ context.DrawImage(bitmap, source, target);
+ }
+ }
+
+ ///
+ /// The loudness as bars along the bottom of the strip, each as loud as its piece makes it (a quieter piece's bars
+ /// lower).
+ ///
+ private void DrawLoudness(DrawingContext context, Rect strip)
+ {
+ if (Loudness is not { Count: > 0 } levels || Duration <= 0)
+ return;
+ var area = strip.Height * 0.45;
+ var bottom = strip.Bottom - 2;
+ var width = RawX(Duration / levels.Count) - RawX(0);
+ var bars = new StreamGeometry();
+ using (var g = bars.Open())
+ {
+ for (var i = 0; i < levels.Count; i++)
+ {
+ var x = RawX(Duration * i / levels.Count);
+ if (x + width < strip.Left || x > strip.Right)
+ continue;
+ var time = (i + 0.5) / levels.Count * Duration;
+ var volume = VolumeAt(time);
+ var height = Math.Min(area, Math.Max(0.5, levels[i] * volume * area));
+ g.BeginFigure(new Point(x, bottom), true);
+ g.LineTo(new Point(x + Math.Max(0.6, width - 0.6), bottom));
+ g.LineTo(new Point(x + Math.Max(0.6, width - 0.6), bottom - height));
+ g.LineTo(new Point(x, bottom - height));
+ g.EndFigure(true);
+ }
+ }
+ using (context.PushOpacity(0.35))
+ context.DrawGeometry(Brushes.Black, new Pen(Brushes.Black, 1.5), bars);
+ using (context.PushOpacity(0.85))
+ context.DrawGeometry(Brushes.White, null, bars);
+ }
+
+ /// How loud the clip makes the video at : its piece's volume (0 when cut out), 1 outside.
+ private double VolumeAt(double seconds)
+ {
+ if (Pieces is not { Count: > 0 } pieces || seconds < Start || seconds > End)
+ return 1;
+ foreach (var piece in pieces)
+ {
+ if (seconds <= piece.End)
+ return piece.IsKept ? Math.Min(piece.Volume, 2) : 0;
+ }
+ return 1;
+ }
+
+ /// Cuts, pieces cut out, the selected piece, and a changed volume or speed.
+ private void DrawPieces(DrawingContext context, Rect strip, IBrush accent)
+ {
+ if (Pieces is not { Count: > 0 } pieces)
+ return;
+ var hatch = new Pen(Brushes.White, 1.2);
+ var cut = new Pen(Brushes.White, 2);
+ var cutShadow = new Pen(new SolidColorBrush(Color.FromArgb(110, 0, 0, 0)), 4);
+ for (var i = 0; i < pieces.Count; i++)
+ {
+ var piece = pieces[i];
+ var rect = new Rect(XOf(piece.Start), strip.Top, Math.Max(1, XOf(piece.End) - XOf(piece.Start)), strip.Height);
+ if (!piece.IsKept)
+ {
+ var blank = piece.State == ClipPieceState.Blank;
+ using (context.PushOpacity(blank ? 0.78 : 0.62))
+ context.FillRectangle(blank ? Brushes.Black : StageBrush ?? Brushes.White, rect);
+ using (context.PushClip(rect))
+ using (context.PushOpacity(blank ? 0.35 : 0.5))
+ {
+ for (var x = rect.Left - rect.Height; x < rect.Right; x += 9)
+ context.DrawLine(blank ? hatch : new Pen(accent, 1.2), new Point(x, rect.Bottom), new Point(x + rect.Height, rect.Top));
+ }
+ }
+ if (i > 0)
+ {
+ var x = Math.Round(rect.Left) + 0.5;
+ context.DrawLine(cutShadow, new Point(x, strip.Top), new Point(x, strip.Bottom));
+ context.DrawLine(cut, new Point(x, strip.Top), new Point(x, strip.Bottom));
+ }
+ if (i == SelectedIndex && pieces.Count > 1)
+ context.DrawRectangle(null, new Pen(accent, 2.5), rect.Deflate(new Thickness(1.5, FrameThickness + 1)), 4, 4);
+ if (piece.IsKept && (piece.Volume != 1 || piece.Speed != 1) && rect.Width > 34)
+ DrawBadge(context, piece, rect);
+ }
+ }
+
+ /// "×2 · 50 %" in the corner of a piece whose speed or volume changed.
+ private static void DrawBadge(DrawingContext context, ClipPiece piece, Rect rect)
+ {
+ var parts = new List();
+ if (piece.Speed != 1)
+ parts.Add("×" + piece.Speed.ToString("0.##", CultureInfo.CurrentCulture));
+ if (piece.Volume != 1)
+ parts.Add(Math.Round(piece.Volume * 100).ToString(CultureInfo.CurrentCulture) + "%");
+ var text = new FormattedText(string.Join(" · ", parts), CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ Typeface.Default, 10.5, Brushes.White);
+ var box = new Rect(rect.Left + 5, rect.Top + 5, text.Width + 10, text.Height + 2);
+ if (box.Right > rect.Right - 3)
+ return;
+ using (context.PushOpacity(0.72))
+ context.DrawRectangle(Brushes.Black, null, box, 5, 5);
+ context.DrawText(text, new Point(box.Left + 5, box.Top + 1));
+ }
+
+ /// A fade in or out as a ramp: a shaded triangle at the clip's end.
+ private void DrawFades(DrawingContext context, Rect strip)
+ {
+ if (!FadeIn && !FadeOut)
+ return;
+ var width = Math.Min(RawX(ClipWindowViewModel.FadeSeconds) - RawX(0), (XOf(End) - XOf(Start)) / 2);
+ width = Math.Max(width, 10);
+ var shade = new SolidColorBrush(Color.FromArgb(120, 0, 0, 0));
+ var line = new Pen(Brushes.White, 1.5);
+ void Ramp(double from, double to)
+ {
+ var geometry = new StreamGeometry();
+ using (var g = geometry.Open())
+ {
+ g.BeginFigure(new Point(from, strip.Top), true);
+ g.LineTo(new Point(to, strip.Top));
+ g.LineTo(new Point(from, strip.Bottom));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(shade, null, geometry);
+ context.DrawLine(line, new Point(from, strip.Bottom), new Point(to, strip.Top));
+ }
+ if (FadeIn)
+ Ramp(XOf(Start), XOf(Start) + width);
+ if (FadeOut)
+ Ramp(XOf(End), XOf(End) - width);
+ }
+
+ private void DrawClipFrame(DrawingContext context, Rect strip, IBrush accent)
+ {
+ var top = strip.Top;
+ var bottom = strip.Bottom;
+ var x0 = XOf(Start);
+ var x1 = XOf(End);
+
+ // Top and bottom of the frame, between the handles.
+ context.FillRectangle(accent, new Rect(x0, top, Math.Max(0, x1 - x0), FrameThickness));
+ context.FillRectangle(accent, new Rect(x0, bottom - FrameThickness, Math.Max(0, x1 - x0), FrameThickness));
+
+ DrawHandle(context, accent, new Rect(x0 - HandleWidth, top, HandleWidth, bottom - top), left: true,
+ ActiveHandle == ClipHandle.Start, _drag == Drag.Start);
+ DrawHandle(context, accent, new Rect(x1, top, HandleWidth, bottom - top), left: false,
+ ActiveHandle == ClipHandle.End, _drag == Drag.End);
+ }
+
+ private void DrawHandle(DrawingContext context, IBrush accent, Rect rect, bool left, bool active, bool dragging)
+ {
+ // Rounded on the outside, square where it meets the clip.
+ var r = StripRadius;
+ var corners = left
+ ? new RoundedRect(rect, new CornerRadius(r, 0, 0, r))
+ : new RoundedRect(rect, new CornerRadius(0, r, r, 0));
+ if (active)
+ {
+ // A soft glow round the handle the arrow keys move and the large picture shows.
+ var glow = new Pen(accent, dragging ? 7 : 5) { LineJoin = PenLineJoin.Round };
+ using (context.PushOpacity(0.28))
+ context.DrawRectangle(null, glow, corners.Rect, r, r);
+ }
+ using (context.PushOpacity(active ? 1 : 0.78))
+ context.DrawRectangle(accent, null, corners);
+
+ // Two short grip lines in the middle.
+ var grip = new Pen(OnAccentBrush ?? Brushes.White, 1.6, lineCap: PenLineCap.Round);
+ var cx = rect.Center.X;
+ var cy = rect.Center.Y;
+ var half = Math.Min(8, rect.Height / 5);
+ context.DrawLine(grip, new Point(cx - 2, cy - half), new Point(cx - 2, cy + half));
+ context.DrawLine(grip, new Point(cx + 2, cy - half), new Point(cx + 2, cy + half));
+ }
+
+ /// The playhead: a line over the strip with a knob on the ruler.
+ private void DrawPlayhead(DrawingContext context, Rect strip)
+ {
+ var x = Math.Round(XOf(Playhead)) + 0.5;
+ var color = Foreground ?? Brushes.Black;
+ var active = ActiveHandle == ClipHandle.Playhead;
+ context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(150, 255, 255, 255)), 4), new Point(x, strip.Top), new Point(x, strip.Bottom));
+ context.DrawLine(new Pen(color, active ? 2 : 1.5), new Point(x, 2), new Point(x, strip.Bottom));
+ var knob = new StreamGeometry();
+ using (var g = knob.Open())
+ {
+ g.BeginFigure(new Point(x - 6, 1), true);
+ g.LineTo(new Point(x + 6, 1));
+ g.LineTo(new Point(x + 6, 8));
+ g.LineTo(new Point(x, RulerHeight - 1));
+ g.LineTo(new Point(x - 6, 8));
+ g.EndFigure(true);
+ }
+ context.DrawGeometry(active ? AccentBrush ?? color : color, null, knob);
+ }
+
+ // ───────────────────────── Dragging ─────────────────────────
+
+ /// What a press at takes: the ruler, a handle, the clip, or beside it the nearer handle.
+ private Drag HitTest(Point point)
+ {
+ if (point.Y < RulerHeight)
+ return Drag.Playhead;
+ var x = point.X;
+ var x0 = XOf(Start);
+ var x1 = XOf(End);
+ var onStart = x >= x0 - HandleWidth - HandleSlop && x <= x0 + HandleSlop;
+ var onEnd = x >= x1 - HandleSlop && x <= x1 + HandleWidth + HandleSlop;
+ if (onStart && onEnd)
+ return Math.Abs(x - (x0 - HandleWidth / 2)) <= Math.Abs(x - (x1 + HandleWidth / 2)) ? Drag.Start : Drag.End;
+ if (onStart)
+ return Drag.Start;
+ if (onEnd)
+ return Drag.End;
+ if (x > x0 && x < x1)
+ return Drag.Clip;
+ return x <= x0 ? Drag.Start : Drag.End;
+ }
+
+ ///
+ /// , or with the magnet on the cut, end or playhead within
+ /// pixels of it (never 's own position).
+ ///
+ private double SnapTime(double seconds, Drag moving)
+ {
+ if (!Snap || Duration <= 0)
+ return seconds;
+ var points = new List();
+ if (Pieces is { } pieces)
+ points.AddRange(pieces.Skip(1).Select(p => p.Start));
+ if (moving != Drag.Start)
+ points.Add(Start);
+ if (moving != Drag.End)
+ points.Add(End);
+ if (moving != Drag.Playhead)
+ points.Add(Playhead);
+ var x = XOf(seconds);
+ var snapped = seconds;
+ var nearest = SnapDistance;
+ foreach (var point in points)
+ {
+ var distance = Math.Abs(XOf(point) - x);
+ if (distance <= nearest)
+ {
+ nearest = distance;
+ snapped = point;
+ }
+ }
+ return snapped;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (Duration <= 0 || !IsEnabled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ var point = e.GetPosition(this);
+ _pressedAt = point;
+ var hit = HitTest(point);
+ var time = TimeAt(point.X);
+ _drag = hit;
+ switch (hit)
+ {
+ case Drag.Playhead:
+ PlayheadMoved?.Invoke(SnapTime(time, Drag.Playhead));
+ break;
+ case Drag.Start:
+ // On the handle: it keeps where it was grabbed. Beside the clip: the handle jumps there.
+ _grabOffset = point.X <= XOf(Start) + HandleSlop && point.X >= XOf(Start) - HandleWidth - HandleSlop ? Start - time : 0;
+ HandleMoved?.Invoke(ClipHandle.Start, SnapTime(time + _grabOffset, Drag.Start));
+ break;
+ case Drag.End:
+ _grabOffset = point.X >= XOf(End) - HandleSlop && point.X <= XOf(End) + HandleWidth + HandleSlop ? End - time : 0;
+ HandleMoved?.Invoke(ClipHandle.End, SnapTime(time + _grabOffset, Drag.End));
+ break;
+ case Drag.Clip:
+ // A click puts the playhead here; a drag moves the clip.
+ _grabOffset = Start - time;
+ _drag = Drag.Pending;
+ break;
+ }
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ InvalidateVisual();
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ var point = e.GetPosition(this);
+ if (_drag == Drag.None)
+ {
+ Cursor = Duration <= 0 ? Cursor.Default
+ : HitTest(point) switch
+ {
+ Drag.Clip => HandCursor,
+ Drag.Playhead => IbeamCursor,
+ _ => SizeWestEastCursor,
+ };
+ return;
+ }
+ if (_drag == Drag.Pending)
+ {
+ if (Math.Abs(point.X - _pressedAt.X) < DragThreshold)
+ return;
+ _drag = Drag.Clip;
+ }
+ var time = TimeAt(point.X);
+ switch (_drag)
+ {
+ case Drag.Clip:
+ var start = time + _grabOffset;
+ // The magnet: the clip's start (or end) snaps.
+ var snappedStart = SnapTime(start, Drag.Start);
+ var snappedEnd = SnapTime(start + (End - Start), Drag.End) - (End - Start);
+ ClipMoved?.Invoke(snappedStart != start ? snappedStart : snappedEnd);
+ break;
+ case Drag.Playhead:
+ PlayheadMoved?.Invoke(SnapTime(time, Drag.Playhead));
+ break;
+ default:
+ var handle = _drag == Drag.Start ? ClipHandle.Start : ClipHandle.End;
+ HandleMoved?.Invoke(handle, SnapTime(time + _grabOffset, _drag));
+ break;
+ }
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (_drag == Drag.Pending)
+ PlayheadMoved?.Invoke(SnapTime(TimeAt(e.GetPosition(this).X), Drag.Playhead));
+ EndDrag(e.Pointer);
+ }
+
+ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
+ {
+ base.OnPointerCaptureLost(e);
+ _drag = Drag.None;
+ InvalidateVisual();
+ }
+
+ private void EndDrag(IPointer pointer)
+ {
+ if (_drag == Drag.None)
+ return;
+ _drag = Drag.None;
+ pointer.Capture(null);
+ InvalidateVisual();
+ }
+}
diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs
index 19eea6e6e..07d28c00f 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -88,6 +88,20 @@ public PresetJudgement Judge(Preset preset)
private PresetJudgement Evaluate(Preset preset, IReadOnlyList formats,
Func installedPlanner, Func, EnginePackageState?> packageEnabling)
{
+ if (preset.IsClipTool)
+ {
+ // Any video among the files: the others are left out when the clip window opens.
+ var videos = formats.Where(f => f.Category == FormatCategory.Video).ToList();
+ if (videos.Count == 0)
+ return new(PresetUse.Unusable, _loc["error.unsupported_source"]);
+ bool CanCut(RoutePlanner planner) => videos.Any(f => ClipTool.CanCut(f.Id, planner));
+ if (CanCut(installedPlanner()))
+ return new(PresetUse.Usable, null);
+ return packageEnabling(CanCut) is { } needed
+ ? new(PresetUse.NeedsPackage, _loc.Format("engine.reason.needs_package", needed.Name))
+ : new(PresetUse.Unusable, _loc["error.no_route"]);
+ }
+
if (preset.IsTool)
{
if (preset.Id == "tool-sheet-studio")
@@ -105,7 +119,7 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format
}
return new(PresetUse.Unusable, _loc["error.unsupported_source"]);
}
- if (formats.Count > 0 && formats.All(f => WritableSingleFrameRasterFormats.Contains(f.Id)))
+ if (preset.IsCropTool && formats.Count > 0 && formats.All(f => WritableSingleFrameRasterFormats.Contains(f.Id)))
return new(PresetUse.Usable, null);
return new(PresetUse.Unusable, _loc["error.unsupported_source"]);
}
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index a4dbb3dda..b8fb4174d 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -332,7 +332,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w
var near = _window?.Position;
Close(animate: false);
if (web is null)
- ShowTool(sources, preset, near);
+ windows.ShowTool(sources, preset, near);
else
_ = ShowToolForBrowserFilesAsync(web, preset, near);
return;
@@ -379,24 +379,13 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset,
}
}
- /// The crop tool on files from a browser: they are saved first, then it opens on them.
+ /// A tool (crop, clip) on files from a browser: they are saved first, then it opens on them.
private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.Preset preset, PixelPoint? near)
{
var files = await conversions.SaveBrowserFilesAsync(web, (placeholder, ex) =>
log.LogWarning(ex, "Saving {File} from a browser for {Preset} failed", Path.GetFileName(placeholder), preset.Id));
if (files.Count > 0)
- ShowTool(files, preset, near);
- }
-
- private void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near)
- {
- if (preset.Id == "tool-sheet-studio")
- {
- foreach (var workbook in files.Where(file => Path.GetExtension(file).Equals(".xlsx", StringComparison.OrdinalIgnoreCase)))
- windows.ShowSpreadsheetPrintStudio(workbook);
- }
- else
- windows.ShowCropOverlay(files, preset, near);
+ windows.ShowTool(files, preset, near);
}
///
diff --git a/src/Filee.App/Services/VideoFrameSource.cs b/src/Filee.App/Services/VideoFrameSource.cs
new file mode 100644
index 000000000..4f984f152
--- /dev/null
+++ b/src/Filee.App/Services/VideoFrameSource.cs
@@ -0,0 +1,57 @@
+// What the clip window reads of a video: its length and pictures of its frames, the loudness of its sound and where
+// it is silent, and the clip played with its sound. Filee's FFmpeg does it; tests stand in for it.
+
+using Filee.Core.Presets;
+using Filee.Engines.Media;
+
+namespace Filee.App.Services;
+
+/// Reads a video for the clip window and plays clips of it.
+public interface IVideoFrameSource
+{
+ /// The video's length and size; throws when it can't be read.
+ Task ProbeAsync(string path, CancellationToken cancellationToken);
+
+ /// Writes the frame at as a JPEG; false when there is none.
+ Task WriteFrameAsync(string path, double seconds, string output, int maxHeight, CancellationToken cancellationToken);
+
+ ///
+ /// The loudness of the sound from to seconds in
+ /// steps (0 to 1); empty without sound.
+ ///
+ Task ReadLoudnessAsync(string path, double start, double end, int count, string workFolder, CancellationToken cancellationToken);
+
+ /// How long the sound of a file plays (music for the clip); throws when it has none.
+ Task ReadSoundLengthAsync(string path, CancellationToken cancellationToken);
+
+ /// The silent stretches between and .
+ Task> FindSilencesAsync(string path, double start, double end, CancellationToken cancellationToken);
+
+ ///
+ /// Plays with its sound: each picture to (BGRA pixels, width,
+ /// height, seconds into the clip) on a worker thread, when it is due. Ends when played or cancelled.
+ ///
+ Task PlayAsync(string path, ClipEdit clip, int height, Action onFrame, CancellationToken cancellationToken);
+}
+
+/// All of it with Filee's FFmpeg (, ).
+public sealed class FfmpegFrameSource : IVideoFrameSource
+{
+ public Task ProbeAsync(string path, CancellationToken cancellationToken) =>
+ VideoFrames.ProbeAsync(path, cancellationToken);
+
+ public Task WriteFrameAsync(string path, double seconds, string output, int maxHeight, CancellationToken cancellationToken) =>
+ VideoFrames.WriteFrameAsync(path, seconds, output, maxHeight, cancellationToken);
+
+ public Task ReadLoudnessAsync(string path, double start, double end, int count, string workFolder, CancellationToken cancellationToken) =>
+ ClipPreview.ReadLoudnessAsync(path, start, end, count, workFolder, cancellationToken);
+
+ public Task ReadSoundLengthAsync(string path, CancellationToken cancellationToken) =>
+ ClipPreview.ReadSoundLengthAsync(path, cancellationToken);
+
+ public Task> FindSilencesAsync(string path, double start, double end, CancellationToken cancellationToken) =>
+ ClipPreview.FindSilencesAsync(path, start, end, cancellationToken);
+
+ public Task PlayAsync(string path, ClipEdit clip, int height, Action onFrame, CancellationToken cancellationToken) =>
+ ClipPreview.PlayAsync(path, clip, height, onFrame, cancellationToken);
+}
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index 9b07ba81e..40c0b75a5 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -7,6 +7,8 @@
using Avalonia.Media;
using Filee.App.ViewModels;
using Filee.App.Views;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
using Filee.Core.Localization;
using Filee.Core.Presets;
using Filee.Core.Settings;
@@ -32,6 +34,9 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem
internal FormatRequestNoticeWindow? FormatRequestNotice => _formatRequestNotice;
private CropOverlayWindow? _cropOverlay;
+ private ClipWindow? _clipWindow;
+
+ internal ClipWindow? ClipWindow => _clipWindow;
private readonly HashSet _spreadsheetStudios = [];
internal IReadOnlyCollection SpreadsheetStudios => _spreadsheetStudios;
@@ -62,6 +67,54 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem
public MainWindow? Main => _main;
+ ///
+ /// Opens the window of a tool preset (crop, cut a clip, the spreadsheet print studio: one per workbook) on the files.
+ /// A tool this version doesn't know opens nothing.
+ ///
+ public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near = null)
+ {
+ if (preset.IsClipTool)
+ ShowClipWindow(files, preset, near);
+ // Before the crop: the print studio has no Tool of its own, which reads as a crop.
+ else if (preset.Id == "tool-sheet-studio")
+ {
+ foreach (var workbook in files.Where(file => Path.GetExtension(file).Equals(".xlsx", StringComparison.OrdinalIgnoreCase)))
+ ShowSpreadsheetPrintStudio(workbook);
+ }
+ else if (preset.IsCropTool)
+ ShowCropOverlay(files, preset, near);
+ }
+
+ ///
+ /// Opens the "Cut a clip" window on the videos among the files. Each clip is written by an ordinary conversion,
+ /// shown in the toast.
+ ///
+ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null)
+ {
+ var videos = files.Where(f => FormatRegistry.Detect(f)?.Category == FormatCategory.Video).ToList();
+ if (videos.Count == 0)
+ return;
+ _clipWindow?.Close();
+ var store = services.GetRequiredService();
+ var catalog = services.GetRequiredService();
+ var conversions = services.GetRequiredService();
+ var log = services.GetService>();
+ var vm = new ClipWindowViewModel(videos, preset, loc, store, new FfmpegFrameSource(),
+ path => ClipTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? ClipTool.FallbackTarget, catalog.CreatePlanner()),
+ (path, conversion) => conversions.Start([path], conversion), log);
+ var win = new ClipWindow { DataContext = vm };
+ win.PositionOnScreen(near);
+ win.Closed += (_, _) =>
+ {
+ if (_clipWindow == win)
+ _clipWindow = null;
+ trimmer.Request();
+ };
+ _clipWindow = win;
+ win.Show();
+ win.Activate();
+ }
+
/// Opens the interactive crop overlay for the files.
public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoint? near = null)
{
diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml
index 2de50bf5f..21ca4ad72 100644
--- a/src/Filee.App/Styles/Icons.axaml
+++ b/src/Filee.App/Styles/Icons.axaml
@@ -35,6 +35,7 @@
M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35ZM12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7ZM7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z
+ M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64M6,8A2,2 0 0,1 4,6A2,2 0 0,1 6,4A2,2 0 0,1 8,6A2,2 0 0,1 6,8M6,20A2,2 0 0,1 4,18A2,2 0 0,1 6,16A2,2 0 0,1 8,18A2,2 0 0,1 6,20M19,3L13,9L15,11L22,4V3H19ZM14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20ZM14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z
diff --git a/src/Filee.App/ViewModels/ClipWindowViewModel.cs b/src/Filee.App/ViewModels/ClipWindowViewModel.cs
new file mode 100644
index 000000000..52d32226c
--- /dev/null
+++ b/src/Filee.App/ViewModels/ClipWindowViewModel.cs
@@ -0,0 +1,1599 @@
+// State of the "Cut a clip" window: one video at a time on a timeline of small pictures with the loudness of its
+// sound, the clip between its start and end cut into pieces (ClipCutter), the playhead, the large picture of the frame
+// being looked at or of the clip playing with its sound, and the conversion that writes the clip.
+
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using Filee.Engines.Media;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.ViewModels;
+
+/// What the arrow keys move and the large picture shows: an end of the clip, or the playhead.
+public enum ClipHandle
+{
+ Start,
+ End,
+ Playhead,
+}
+
+public sealed partial class ClipWindowViewModel : ObservableObject, IDisposable
+{
+ /// Pictures on the timeline.
+ public const int ThumbnailCount = 16;
+
+ /// Steps of the sound's loudness drawn on the timeline.
+ public const int LoudnessSteps = 480;
+
+ /// Pictures of the clip's own stretch of the video, for the sequence.
+ public const int DetailCount = 14;
+
+ /// Steps of the loudness of the clip's own stretch (A1) and of the music (A2).
+ public const int DetailLoudnessSteps = 400;
+
+ /// How long the clip's ends rest before the sequence's pictures of it are taken again.
+ private static readonly TimeSpan DetailDelay = TimeSpan.FromMilliseconds(350);
+
+ /// Height of a timeline picture (twice its size on screen, for high-DPI displays).
+ private const int ThumbnailHeight = 96;
+
+ /// Height of the large picture of the frame looked at.
+ private const int PreviewHeight = 720;
+
+ /// Height of the pictures while the clip plays (smaller: they come 25 times a second).
+ private const int PlayHeight = 360;
+
+ /// FFmpeg runs at the same time while the timeline fills: one per picture would starve the window.
+ private const int ParallelThumbnails = 3;
+
+ /// How long a fade lasts when switched on.
+ public const double FadeSeconds = 1;
+
+ /// How long a short message (silences removed, nothing to cut) stays.
+ private static readonly TimeSpan MessageTime = TimeSpan.FromSeconds(4);
+
+ /// How long a handle rests before its exact frame is taken (the nearest timeline picture shows meanwhile).
+ private static readonly TimeSpan PreviewDelay = TimeSpan.FromMilliseconds(90);
+
+ /// The clip a video starts with: its first this many seconds, or all of a shorter one.
+ public const double FirstClipLength = 30;
+
+ /// The quick lengths, in seconds from the start handle.
+ public static IReadOnlyList QuickLengths { get; } = [15, 30, 60];
+
+ private readonly IReadOnlyList _files;
+ private readonly Preset _tool;
+ private readonly ILocalizer _loc;
+ private readonly UserDataStore _store;
+ private readonly IVideoFrameSource _frames;
+ private readonly Func _targetFor;
+ private readonly Action _export;
+ private readonly ILogger? _log;
+ private readonly string _workFolder;
+
+ private CancellationTokenSource _fileCts = new();
+ private CancellationTokenSource? _previewCts;
+ private CancellationTokenSource? _playCts;
+ private CancellationTokenSource? _detailCts;
+ private ClipEdit? _edit;
+ private (double Start, double End) _detailWanted;
+ private int _detailNumber;
+ private int _trimIndex = -1;
+ private bool _trimEnd;
+ private ClipCutter? _cutter;
+ private VideoSummary? _video;
+ private Bitmap? _ownPreview; // a frame taken for the large picture (not a timeline one), disposed with it
+ private readonly WriteableBitmap?[] _playFrames = new WriteableBitmap?[2]; // what plays, written in turn
+ private readonly Lock _playFramesLock = new();
+ private int _playFrame;
+ private int _previewNumber;
+ private bool _syncing;
+ private bool _closed;
+
+ [ObservableProperty] private int _currentIndex;
+ [ObservableProperty] private string _currentFileName = "";
+ [ObservableProperty] private string _counterText = "";
+ [ObservableProperty] private bool _hasMultipleFiles;
+ [ObservableProperty] private string _saveText = "";
+
+ /// Reading the video (its length): the timeline and the buttons wait.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanEdit))]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DurationText))]
+ [NotifyPropertyChangedFor(nameof(Can15))]
+ [NotifyPropertyChangedFor(nameof(Can30))]
+ [NotifyPropertyChangedFor(nameof(Can60))]
+ private double _duration;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(StartText))]
+ [NotifyPropertyChangedFor(nameof(PreviewTime))]
+ [NotifyPropertyChangedFor(nameof(PreviewLabel))]
+ private double _start;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(EndText))]
+ [NotifyPropertyChangedFor(nameof(PreviewTime))]
+ [NotifyPropertyChangedFor(nameof(PreviewLabel))]
+ private double _end;
+
+ /// How long the clip plays (its pieces at their speeds, without those cut out).
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(LengthText))]
+ private double _length;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsStartActive))]
+ [NotifyPropertyChangedFor(nameof(IsEndActive))]
+ [NotifyPropertyChangedFor(nameof(PreviewTime))]
+ [NotifyPropertyChangedFor(nameof(PreviewLabel))]
+ private ClipHandle _activeHandle;
+
+ /// Where the playhead stands in the video: where the blade cuts and playing starts.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(PlayheadText))]
+ [NotifyPropertyChangedFor(nameof(PreviewTime))]
+ [NotifyPropertyChangedFor(nameof(PreviewLabel))]
+ private double _playhead;
+
+ /// The pieces between the start and the end (a copy, new after each change).
+ [ObservableProperty] private IReadOnlyList _pieces = [];
+
+ /// The piece the volume, speed and Delete apply to, or -1.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeleteText))]
+ private int _selectedIndex = -1;
+
+ /// Every selected piece (in order), with the one clicked last; empty: none.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeleteText))]
+ [NotifyPropertyChangedFor(nameof(SelectionText))]
+ private IReadOnlyList _selectedIndices = [];
+
+ /// Playing is about to start (FFmpeg seeks and starts): the window says so until the first picture.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(BusyText))]
+ [NotifyPropertyChangedFor(nameof(IsBusy))]
+ private bool _isBuffering;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(BusyText))]
+ [NotifyPropertyChangedFor(nameof(IsBusy))]
+ private bool _isAddingMusic;
+
+ /// The track selected in the sequence: a piece's pictures, its sound, or the music.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeleteText))]
+ [NotifyPropertyChangedFor(nameof(IsMusicSelected))]
+ private ClipLane _selectedLane;
+
+ /// The playhead in the sequence: seconds into the clip as it plays.
+ [ObservableProperty] private double _sequencePlayhead;
+
+ /// The music under the clip (a copy), or null.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasMusic))]
+ private ClipMusic? _music;
+
+ /// The loudness of the whole music file, for A2.
+ [ObservableProperty] private IReadOnlyList _musicLoudness = [];
+
+ /// The loudness of the clip's stretch of the video ( to ), for A1.
+ [ObservableProperty] private IReadOnlyList _detailLoudness = [];
+
+ /// The stretch of the video and show.
+ [ObservableProperty] private double _detailStart;
+
+ [ObservableProperty] private double _detailEnd;
+
+ /// The loudness of the sound over the video (0 to 1), empty until read or without sound.
+ [ObservableProperty] private IReadOnlyList _loudness = [];
+
+ /// The large picture: the frame looked at, or the clip playing.
+ [ObservableProperty] private Bitmap? _preview;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(PlayToolTip))]
+ private bool _isPlaying;
+
+ ///
+ /// The magnet: a piece cut out closes up (else its time stays as a pause), and handles and the playhead snap to
+ /// cuts and to each other.
+ ///
+ [ObservableProperty] private bool _isMagnet = true;
+
+ /// The timeline shows the clip and a little around it instead of the whole video (Z).
+ [ObservableProperty] private bool _isZoomed;
+
+ /// The stretch of the video the timeline shows (all of it unless zoomed).
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ViewStartText))]
+ private double _viewStart;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ViewEndText))]
+ private double _viewEnd;
+
+ /// Copy the streams (fast, cut at key frames) rather than encode the clip. Remembered in the tool.
+ [ObservableProperty] private bool _isFast = true;
+
+ /// True when the clip can be copied: one stretch as recorded, no fades.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(FastToolTip))]
+ private bool _canFast = true;
+
+ [ObservableProperty] private bool _canUndo;
+ [ObservableProperty] private bool _canRedo;
+ [ObservableProperty] private bool _hasGaps;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(BusyText))]
+ [NotifyPropertyChangedFor(nameof(IsBusy))]
+ private bool _isFindingSilence;
+
+ /// A short note after an action (silences removed, nowhere to cut), shown for a moment.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasMessage))]
+ private string _message = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasStatusNotice))]
+ private string _statusNotice = "";
+
+ /// Every file is done and some could not be opened: the window stays on until closed.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsEditing))]
+ [NotifyPropertyChangedFor(nameof(CanEdit))]
+ private bool _isFinished;
+
+ [ObservableProperty] private string _summary = "";
+
+ /// The timeline's pictures, in order; null until taken (or when FFmpeg found none).
+ public ObservableCollection Thumbnails { get; } = [];
+
+ /// Pictures of the clip's stretch of the video, for V1 (new ones once its ends rest).
+ [ObservableProperty] private IReadOnlyList _detailThumbnails = [];
+
+ /// "file: reason" for each file that could not be opened.
+ public ObservableCollection Problems { get; } = [];
+
+ /// The speeds a piece can play at.
+ public IReadOnlyList> Speeds { get; } =
+ ClipPart.Speeds.Select(s => new Choice(s, s.ToString("0.##", CultureInfo.CurrentCulture) + "×")).ToList();
+
+ public bool HasStatusNotice => !string.IsNullOrWhiteSpace(StatusNotice);
+ public bool HasMusic => Music is not null;
+
+ /// What the window is busy with ("Preparing…" before the clip plays), or empty.
+ /// The music picker's title and the name of the files it lists.
+ public string MusicPickerTitle => _loc["clip.music_pick"];
+
+ public string MusicFilesName => _loc["clip.music_files"];
+
+ public string BusyText => IsBuffering ? _loc["clip.preparing"]
+ : IsFindingSilence ? _loc["clip.finding_silence"]
+ : IsAddingMusic ? _loc["clip.loading_music"]
+ : "";
+
+ public bool IsBusy => IsBuffering || IsFindingSilence || IsAddingMusic;
+
+ /// "3 pieces selected" when several are, else empty.
+ public string SelectionText => SelectedIndices.Count > 1 ? _loc.Format("clip.selected_count", SelectedIndices.Count) : "";
+
+ /// The pieces the piece actions apply to: the selection, or the current piece.
+ public IReadOnlyList Targets =>
+ SelectedIndices.Count > 0 ? SelectedIndices.Where(i => i < Pieces.Count).ToList()
+ : CurrentPiece >= 0 ? [CurrentPiece] : [];
+ public bool IsMusicSelected => SelectedLane == ClipLane.Music && HasMusic;
+ public bool HasMessage => !string.IsNullOrEmpty(Message);
+ public bool IsEditing => !IsFinished;
+ public bool CanEdit => !IsFinished && !IsLoading;
+ public bool IsStartActive => ActiveHandle == ClipHandle.Start;
+ public bool IsEndActive => ActiveHandle == ClipHandle.End;
+ public string StartText => ClipRange.Display(Start);
+ public string EndText => ClipRange.Display(End);
+ public string LengthText => ClipRange.Display(Length);
+ public string DurationText => ClipRange.Display(Duration);
+ public string PlayheadText => ClipRange.Display(Playhead);
+ public string ViewStartText => ClipRange.Display(ViewStart);
+ public string ViewEndText => ClipRange.Display(ViewEnd);
+ public bool Can15 => Duration > QuickLengths[0];
+ public bool Can30 => Duration > QuickLengths[1];
+ public bool Can60 => Duration > QuickLengths[2];
+ public string PlayToolTip => _loc[IsPlaying ? "clip.pause" : "clip.play"];
+ public string FastToolTip => _loc[CanFast ? "clip.fast_hint" : "clip.fast_needs_encode"];
+
+ /// The piece the piece controls apply to: the selected one, else the one at the playhead, else the first.
+ public int CurrentPiece =>
+ SelectedIndex >= 0 && SelectedIndex < Pieces.Count ? SelectedIndex
+ : _cutter?.IndexAt(Playhead) is >= 0 and var at ? at
+ : Pieces.Count > 0 ? 0 : -1;
+
+ /// What Delete does to the selection: cut a piece out, close its gap, put it back, mute its sound, take the music away.
+ public string DeleteText => SelectedIndices.Count > 1 && !IsMusicSelected
+ ? $"{_loc[DeleteKey()]} ({SelectedIndices.Count})"
+ : _loc[DeleteKey()];
+
+ private string DeleteKey()
+ {
+ if (IsMusicSelected)
+ return "clip.remove_music";
+ if (CurrentPiece < 0 || CurrentPiece >= Pieces.Count)
+ return "clip.delete";
+ var piece = Pieces[CurrentPiece];
+ if (piece.State == ClipPieceState.Blank)
+ return "clip.close_gap";
+ if (piece.State == ClipPieceState.Removed)
+ return "clip.restore";
+ if (SelectedLane == ClipLane.Audio)
+ return piece.Muted ? "clip.unmute" : "clip.mute";
+ return "clip.delete";
+ }
+
+ /// The volume slider: the music's when it is selected, else the current piece's (0 to 2, 200 %).
+ public double SelectedVolume
+ {
+ get => IsMusicSelected ? Music!.Volume : CurrentPiece >= 0 ? Pieces[CurrentPiece].Volume : 1;
+ set
+ {
+ if (_syncing || _cutter is null)
+ return;
+ StopPlaying();
+ if (IsMusicSelected)
+ _cutter.SetMusicVolume(value);
+ else
+ {
+ var targets = Targets;
+ _cutter.Batch(() =>
+ {
+ foreach (var index in targets)
+ _cutter.SetVolume(index, value);
+ }, join: "volume " + string.Join(",", targets));
+ }
+ }
+ }
+
+ /// The current piece's own sound is muted (its picture plays).
+ public bool IsCurrentMuted
+ {
+ get => CurrentPiece >= 0 && CurrentPiece < Pieces.Count && Pieces[CurrentPiece].Muted;
+ set
+ {
+ if (_syncing || _cutter is null || CurrentPiece < 0)
+ return;
+ StopPlaying();
+ var targets = Targets;
+ _cutter.Batch(() =>
+ {
+ foreach (var index in targets)
+ _cutter.SetMuted(index, value);
+ });
+ }
+ }
+
+ public string VolumeText => $"{Math.Round(SelectedVolume * 100).ToString(CultureInfo.CurrentCulture)} %";
+
+ /// The current piece's speed.
+ public Choice? SelectedSpeed
+ {
+ get
+ {
+ var speed = CurrentPiece >= 0 ? Pieces[CurrentPiece].Speed : 1;
+ return Speeds.MinBy(s => Math.Abs(s.Value - speed));
+ }
+ set
+ {
+ if (_syncing || _cutter is null || CurrentPiece < 0 || value is null)
+ return;
+ StopPlaying();
+ var targets = Targets;
+ _cutter.Batch(() =>
+ {
+ foreach (var index in targets)
+ _cutter.SetSpeed(index, value.Value);
+ });
+ }
+ }
+
+ /// The clip fades in from black and silence.
+ public bool HasFadeIn
+ {
+ get => _cutter?.FadeIn > 0;
+ set
+ {
+ if (_syncing || _cutter is null)
+ return;
+ StopPlaying();
+ _cutter.FadeIn = value ? FadeSeconds : 0;
+ }
+ }
+
+ /// The clip fades out to black and silence.
+ public bool HasFadeOut
+ {
+ get => _cutter?.FadeOut > 0;
+ set
+ {
+ if (_syncing || _cutter is null)
+ return;
+ StopPlaying();
+ _cutter.FadeOut = value ? FadeSeconds : 0;
+ }
+ }
+
+ ///
+ /// The time of the frame in the large picture: the start, the clip's last frame for the end (the end itself is just
+ /// after it), or the playhead; never past the video's last frame.
+ ///
+ public double PreviewTime
+ {
+ get
+ {
+ var frame = _video?.FrameLength ?? 1 / 30.0;
+ var time = ActiveHandle switch
+ {
+ ClipHandle.Start => Start,
+ ClipHandle.End => Math.Max(Start, End - frame),
+ _ => Playhead,
+ };
+ return Math.Clamp(time, 0, _video?.LastFrameTime ?? Math.Max(0, Duration));
+ }
+ }
+
+ /// "Start 0:12.4" over the large picture.
+ public string PreviewLabel => ActiveHandle switch
+ {
+ ClipHandle.Start => $"{_loc["clip.start"]} {StartText}",
+ ClipHandle.End => $"{_loc["clip.end"]} {EndText}",
+ _ => $"{_loc["clip.playhead"]} {PlayheadText}",
+ };
+
+ /// "15 s", "30 s", "60 s" on the quick length buttons.
+ public string Length15Text => _loc.Format("clip.seconds", QuickLengths[0]);
+ public string Length30Text => _loc.Format("clip.seconds", QuickLengths[1]);
+ public string Length60Text => _loc.Format("clip.seconds", QuickLengths[2]);
+
+ public event Action? CloseRequested;
+
+ /// The videos, one after the other.
+ /// The "Cut a clip" preset: name, output rule and the media options of an encoded clip.
+ /// Length, pictures, sound and playing of a video (FFmpeg).
+ /// The format a clip of the file at a path is written in.
+ /// Starts the conversion that writes a clip (the file, the conversion's preset).
+ public ClipWindowViewModel(IReadOnlyList files, Preset tool, ILocalizer loc, UserDataStore store, IVideoFrameSource frames,
+ Func targetFor, Action export, ILogger? log = null)
+ {
+ _files = files;
+ _tool = tool;
+ _loc = loc;
+ _store = store;
+ _frames = frames;
+ _targetFor = targetFor;
+ _export = export;
+ _log = log;
+ _isFast = !tool.Media.ExactCut;
+ _hasMultipleFiles = files.Count > 1;
+ // Named like a job's work folder, so the sweep after a crash (JobQueue.DeleteStaleWorkFolders) takes it too.
+ _workFolder = Path.Combine(Filee.Core.Conversion.JobQueue.DefaultWorkRoot, Guid.NewGuid().ToString("N")[..10]);
+ loc.LanguageChanged += OnLanguageChanged;
+ _ = LoadFileAsync(0);
+ }
+
+ /// Opens file : its length, the first clip, and the timeline filling in.
+ public async Task LoadFileAsync(int index)
+ {
+ if (_closed)
+ return;
+ StopPlaying();
+ if (index < 0 || index >= _files.Count)
+ {
+ Finish();
+ return;
+ }
+
+ await _fileCts.CancelAsync();
+ _fileCts.Dispose();
+ _fileCts = new CancellationTokenSource();
+ var ct = _fileCts.Token;
+ _previewCts?.Cancel();
+
+ IsLoading = true;
+ CurrentIndex = index;
+ var path = _files[index];
+ CurrentFileName = Path.GetFileName(path);
+ CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count);
+ SaveText = _loc[index < _files.Count - 1 ? "clip.save_next" : "clip.save"];
+ ClearPictures();
+ UseCutter(null);
+ Loudness = [];
+ MusicLoudness = [];
+ _detailWanted = default;
+ var oldDetail = DetailThumbnails;
+ DetailThumbnails = [];
+ DetailLoudness = [];
+ foreach (var picture in oldDetail)
+ picture?.Dispose();
+
+ VideoSummary video;
+ try
+ {
+ Directory.CreateDirectory(_workFolder);
+ video = await _frames.ProbeAsync(path, ct);
+ if (video.Duration <= 0)
+ throw new InvalidDataException("The video doesn't say how long it is.");
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogWarning(ex, "Could not open {File} to cut a clip", path);
+ if (_closed || ct.IsCancellationRequested)
+ return;
+ AddProblem(path, ex is InvalidDataException ? "error.unsupported_source" : "error.conversion_failed");
+ await LoadFileAsync(index + 1);
+ return;
+ }
+ if (ct.IsCancellationRequested)
+ return;
+
+ _video = video;
+ Duration = video.Duration;
+ ViewEnd = video.Duration;
+ SelectedLane = ClipLane.Video;
+ SelectedIndices = [];
+ UseCutter(new ClipCutter(video.Duration, new ClipRange(0, Math.Min(video.Duration, FirstClipLength))));
+ ActiveHandle = ClipHandle.Start;
+ Playhead = 0;
+ SelectedIndex = -1;
+ IsLoading = false;
+ SchedulePreview(immediately: true);
+ _ = ReadLoudnessAsync(path, video.Duration, ct);
+ await FillTimelineAsync(path, video.Duration, ct);
+ }
+
+ /// Takes the timeline's pictures, a few at a time, each shown as soon as it is there.
+ private async Task FillTimelineAsync(string path, double duration, CancellationToken ct)
+ {
+ var folder = Path.Combine(_workFolder, CurrentIndex.ToString(CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(folder);
+ using var gate = new SemaphoreSlim(ParallelThumbnails);
+ var tasks = Enumerable.Range(0, ThumbnailCount).Select(async i =>
+ {
+ await gate.WaitAsync(ct);
+ string? file = null;
+ try
+ {
+ var output = Path.Combine(folder, $"thumb_{i:00}.jpg");
+ if (await _frames.WriteFrameAsync(path, VideoFrames.ThumbnailTime(i, ThumbnailCount, duration), output, ThumbnailHeight, ct))
+ file = output;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException)
+ {
+ _log?.LogDebug(ex, "No timeline picture {Index} of {File}", i, path);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ if (file is null || ct.IsCancellationRequested || _closed)
+ return;
+ var bitmap = Load(file);
+ if (bitmap is null)
+ return;
+ if (ct.IsCancellationRequested || _closed || i >= Thumbnails.Count)
+ {
+ bitmap.Dispose();
+ return;
+ }
+ Thumbnails[i] = bitmap;
+ // The large picture shows the nearest timeline picture until its own frame is there.
+ if (Preview is null)
+ Preview = bitmap;
+ }).ToList();
+ try
+ {
+ await Task.WhenAll(tasks);
+ }
+ catch (OperationCanceledException)
+ {
+ // Another file opened, or the window closed.
+ }
+ }
+
+ private async Task ReadLoudnessAsync(string path, double duration, CancellationToken ct)
+ {
+ try
+ {
+ var loudness = await _frames.ReadLoudnessAsync(path, 0, duration, LoudnessSteps, _workFolder, ct);
+ if (!ct.IsCancellationRequested && !_closed)
+ Loudness = loudness;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogDebug(ex, "No loudness of {File}", path);
+ }
+ }
+
+ // ───────────────────────── The clip and its pieces ─────────────────────────
+
+ private void UseCutter(ClipCutter? cutter)
+ {
+ if (_cutter is not null)
+ _cutter.Changed -= Sync;
+ _cutter = cutter;
+ if (cutter is not null)
+ cutter.Changed += Sync;
+ Sync();
+ }
+
+ /// Everything shown of the clip, after a change.
+ private void Sync()
+ {
+ _syncing = true;
+ try
+ {
+ var cutter = _cutter;
+ _edit = cutter?.ToEdit();
+ Pieces = cutter?.Pieces.ToList() ?? [];
+ Music = cutter?.Music;
+ Start = cutter?.Start ?? 0;
+ End = cutter?.End ?? 0;
+ Length = cutter?.Length ?? 0;
+ CanUndo = cutter?.CanUndo == true;
+ CanRedo = cutter?.CanRedo == true;
+ HasGaps = cutter?.HasGaps == true;
+ CanFast = cutter is null || _edit!.IsPlainCut;
+ FitView(refit: false);
+ SequencePlayhead = _edit?.ClipTimeAt(Playhead) ?? 0;
+ if (cutter is not null && (cutter.Start, cutter.End) != _detailWanted)
+ ScheduleDetail();
+ if (SelectedIndex >= Pieces.Count)
+ SelectedIndex = Pieces.Count - 1;
+ if (SelectedIndices.Any(i => i >= Pieces.Count))
+ SelectedIndices = SelectedIndices.Where(i => i < Pieces.Count).ToList();
+ OnPropertyChanged(nameof(SelectedVolume));
+ OnPropertyChanged(nameof(VolumeText));
+ OnPropertyChanged(nameof(SelectedSpeed));
+ OnPropertyChanged(nameof(HasFadeIn));
+ OnPropertyChanged(nameof(HasFadeOut));
+ OnPropertyChanged(nameof(DeleteText));
+ OnPropertyChanged(nameof(CurrentPiece));
+ OnPropertyChanged(nameof(IsCurrentMuted));
+ OnPropertyChanged(nameof(IsMusicSelected));
+ }
+ finally
+ {
+ _syncing = false;
+ }
+ }
+
+ partial void OnIsZoomedChanged(bool value) => FitView(refit: true);
+
+ /// The zoom button (Z): the clip close up, or the whole video.
+ [RelayCommand]
+ public void ToggleZoom() => IsZoomed = !IsZoomed;
+
+ ///
+ /// The timeline's view: the whole video, or zoomed the clip with a little room around it. Zoomed, it is fitted
+ /// again only when the clip no longer fits (: always), so a handle dragged to the view's
+ /// edge doesn't move the view under the pointer.
+ ///
+ private void FitView(bool refit)
+ {
+ if (!IsZoomed || _cutter is null || Duration <= 0)
+ {
+ ViewStart = 0;
+ ViewEnd = Duration;
+ return;
+ }
+ var start = _cutter.Start;
+ var end = _cutter.End;
+ if (!refit && start >= ViewStart - 1e-9 && end <= ViewEnd + 1e-9 && ViewEnd > ViewStart)
+ return;
+ var room = Math.Max((end - start) * 0.15, 1);
+ ViewStart = Math.Max(0, start - room);
+ ViewEnd = Math.Min(Duration, end + room);
+ }
+
+ partial void OnSelectedIndexChanged(int value) => RefreshPieceControls();
+ partial void OnPlayheadChanged(double value)
+ {
+ SequencePlayhead = _edit?.ClipTimeAt(value) ?? 0;
+ if (SelectedIndex < 0)
+ RefreshPieceControls();
+ }
+
+ partial void OnSelectedLaneChanged(ClipLane value) => RefreshPieceControls();
+
+ private void RefreshPieceControls()
+ {
+ _syncing = true;
+ try
+ {
+ OnPropertyChanged(nameof(CurrentPiece));
+ OnPropertyChanged(nameof(SelectedVolume));
+ OnPropertyChanged(nameof(VolumeText));
+ OnPropertyChanged(nameof(SelectedSpeed));
+ OnPropertyChanged(nameof(DeleteText));
+ OnPropertyChanged(nameof(IsCurrentMuted));
+ OnPropertyChanged(nameof(Targets));
+ }
+ finally
+ {
+ _syncing = false;
+ }
+ }
+
+ // ───────────────────────── The sequence ─────────────────────────
+
+ /// A piece (or the music) clicked in the sequence: it is what the volume, speed and Delete apply to.
+ public void SelectPiece(int index, ClipLane lane) => SelectPiece(index, lane, ClipSelect.Only);
+
+ ///
+ /// A piece clicked: selects it alone, (Ctrl) adds it
+ /// to the selection or takes it out, (Shift) selects every piece from the last one
+ /// clicked to it.
+ ///
+ public void SelectPiece(int index, ClipLane lane, ClipSelect mode)
+ {
+ if (!CanEdit)
+ return;
+ SelectedLane = lane;
+ if (lane == ClipLane.Music || index < 0 || index >= Pieces.Count)
+ {
+ SelectedIndices = [];
+ RefreshPieceControls();
+ return;
+ }
+ var anchor = SelectedIndex >= 0 && SelectedIndex < Pieces.Count ? SelectedIndex : index;
+ SelectedIndices = mode switch
+ {
+ ClipSelect.Toggle => SelectedIndices.Contains(index)
+ ? SelectedIndices.Where(i => i != index).ToList()
+ : [.. SelectedIndices.Append(index).Order()],
+ ClipSelect.Range => Enumerable.Range(Math.Min(anchor, index), Math.Abs(anchor - index) + 1)
+ .Where(i => Pieces[i].State != ClipPieceState.Removed).ToList(),
+ _ => [index],
+ };
+ if (mode != ClipSelect.Range)
+ SelectedIndex = SelectedIndices.Contains(index) ? index : SelectedIndices.LastOrDefault(-1);
+ RefreshPieceControls();
+ }
+
+ ///
+ /// The pieces a drag across the sequence covered, from to seconds
+ /// into the clip, selected (added to the selection with , Ctrl).
+ ///
+ public void SelectAcross(double from, double to, ClipLane lane, bool add = false)
+ {
+ if (!CanEdit || lane == ClipLane.Music)
+ return;
+ var (low, high) = (Math.Min(from, to), Math.Max(from, to));
+ var covered = new List();
+ var at = 0.0;
+ for (var i = 0; i < Pieces.Count; i++)
+ {
+ var length = Pieces[i].Length;
+ if (length > 0 && at < high && at + length > low)
+ covered.Add(i);
+ at += length;
+ }
+ SelectedLane = lane;
+ SelectedIndices = add ? [.. SelectedIndices.Union(covered).Order()] : covered;
+ if (covered.Count > 0)
+ SelectedIndex = covered[^1];
+ RefreshPieceControls();
+ }
+
+ /// Ctrl+A: every piece of the clip (gaps too) selected.
+ [RelayCommand]
+ public void SelectAll()
+ {
+ if (!CanEdit)
+ return;
+ SelectedLane = ClipLane.Video;
+ SelectedIndices = Enumerable.Range(0, Pieces.Count).Where(i => Pieces[i].State != ClipPieceState.Removed).ToList();
+ RefreshPieceControls();
+ }
+
+ /// Back to the one piece clicked last (Esc with several selected). False when there were not several.
+ public bool CollapseSelection()
+ {
+ if (SelectedIndices.Count <= 1)
+ return false;
+ SelectedIndices = SelectedIndex >= 0 ? [SelectedIndex] : [];
+ RefreshPieceControls();
+ return true;
+ }
+
+ /// The playhead put in the sequence, into the clip as it plays.
+ public void MoveSequencePlayhead(double seconds)
+ {
+ if (!CanEdit || _edit is null || !double.IsFinite(seconds))
+ return;
+ MovePlayhead(_edit.SourceTimeAt(Math.Clamp(seconds, 0, _edit.Length)));
+ }
+
+ /// An edge of piece starts to be dragged (its end, or its start).
+ public void BeginTrim(int index, bool end)
+ {
+ StopPlaying();
+ _trimIndex = index;
+ _trimEnd = end;
+ }
+
+ ///
+ /// The dragged edge is at of the video: outwards it takes back what was cut next to the
+ /// piece, inwards it cuts that much out. The large picture shows the frame at the edge.
+ ///
+ public void Trim(double seconds)
+ {
+ if (!CanEdit || _cutter is null || _trimIndex < 0 || !double.IsFinite(seconds))
+ return;
+ var at = _cutter.TrimEdge(_trimIndex, _trimEnd, seconds);
+ if (!double.IsFinite(at))
+ return;
+ // Pieces cut out before it may have been used up: find the piece again by its edge.
+ var pieces = _cutter.Pieces;
+ var index = -1;
+ for (var i = 0; i < pieces.Count; i++)
+ {
+ if (Math.Abs((_trimEnd ? pieces[i].End : pieces[i].Start) - at) < 1e-9 && pieces[i].IsKept)
+ index = i;
+ }
+ if (index >= 0)
+ {
+ _trimIndex = index;
+ SelectedIndex = index;
+ }
+ Playhead = _trimEnd ? Math.Max(0, at - (_video?.FrameLength ?? 1 / 30.0)) : at;
+ ActiveHandle = ClipHandle.Playhead;
+ SchedulePreview();
+ }
+
+ /// Closes the gap piece left: what follows moves up.
+ public void CloseGap(int index)
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.CloseGap(index);
+ }
+ }
+
+ /// A piece's volume line dragged on A1.
+ public void SetPieceVolume(int index, double volume)
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.SetVolume(index, volume);
+ }
+ }
+
+ /// Puts the sound of the file at under the clip as music (A2), from the clip's start.
+ public async Task AddMusicAsync(string path)
+ {
+ if (!CanEdit || _cutter is null)
+ return;
+ StopPlaying();
+ var cutter = _cutter;
+ var ct = _fileCts.Token;
+ IsAddingMusic = true;
+ try
+ {
+ var length = await _frames.ReadSoundLengthAsync(path, ct);
+ if (ct.IsCancellationRequested || !ReferenceEquals(cutter, _cutter))
+ return;
+ cutter.SetMusic(new ClipMusic { Path = path, Length = length });
+ SelectedLane = ClipLane.Music;
+ RefreshPieceControls();
+ MusicLoudness = [];
+ var loudness = await _frames.ReadLoudnessAsync(path, 0, length, DetailLoudnessSteps, _workFolder, ct);
+ if (!ct.IsCancellationRequested && cutter.Music?.Path == path)
+ MusicLoudness = loudness;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogWarning(ex, "Could not add {File} as music", path);
+ ShowMessage(_loc["clip.no_music"]);
+ }
+ finally
+ {
+ IsAddingMusic = false;
+ }
+ }
+
+ /// Takes the music away.
+ [RelayCommand]
+ public void RemoveMusic()
+ {
+ if (CanEdit && _cutter?.Music is not null)
+ {
+ StopPlaying();
+ _cutter.SetMusic(null);
+ MusicLoudness = [];
+ SelectedLane = ClipLane.Video;
+ }
+ }
+
+ /// The music dragged to start seconds into the clip.
+ public void MoveMusic(double offset)
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.MoveMusic(offset);
+ }
+ }
+
+ /// The music's volume line dragged on A2.
+ public void SetMusicVolume(double volume)
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.SetMusicVolume(volume);
+ }
+ }
+
+ /// Takes new pictures and loudness of the clip's stretch of the video once its ends rest.
+ private void ScheduleDetail()
+ {
+ if (_cutter is null || _closed || _video is null)
+ return;
+ _detailWanted = (_cutter.Start, _cutter.End);
+ _detailCts?.Cancel();
+ _detailCts?.Dispose();
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(_fileCts.Token);
+ _detailCts = cts;
+ _ = TakeDetailAsync(_files[CurrentIndex], _detailWanted.Start, _detailWanted.End, DetailThumbnails.Count == 0 ? TimeSpan.Zero : DetailDelay, cts.Token);
+ }
+
+ private async Task TakeDetailAsync(string path, double start, double end, TimeSpan delay, CancellationToken ct)
+ {
+ try
+ {
+ if (delay > TimeSpan.Zero)
+ await Task.Delay(delay, ct);
+ var folder = Path.Combine(_workFolder, "detail" + (++_detailNumber).ToString(CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(folder);
+ var loudness = _frames.ReadLoudnessAsync(path, start, end, DetailLoudnessSteps, folder, ct);
+ var pictures = new Bitmap?[DetailCount];
+ var span = (end - start) / DetailCount;
+ for (var i = 0; i < DetailCount; i++)
+ {
+ var output = Path.Combine(folder, $"d_{i:00}.jpg");
+ if (await _frames.WriteFrameAsync(path, start + span * (i + 0.5), output, ThumbnailHeight, ct))
+ pictures[i] = Load(output);
+ }
+ var levels = await loudness;
+ if (ct.IsCancellationRequested || _closed)
+ {
+ foreach (var picture in pictures)
+ picture?.Dispose();
+ return;
+ }
+ var old = DetailThumbnails;
+ DetailStart = start;
+ DetailEnd = end;
+ DetailThumbnails = pictures;
+ DetailLoudness = levels;
+ foreach (var picture in old)
+ picture?.Dispose();
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogDebug(ex, "No pictures of the clip of {File}", path);
+ }
+ }
+
+ ///
+ /// Moves to and makes it the active one. An end moved past the
+ /// other takes the clip along at its length; the playhead goes anywhere in the video.
+ ///
+ public void MoveHandle(ClipHandle handle, double seconds)
+ {
+ if (!CanEdit || _cutter is null || !double.IsFinite(seconds))
+ return;
+ StopPlaying();
+ ActiveHandle = handle;
+ switch (handle)
+ {
+ case ClipHandle.Start:
+ _cutter.MoveStart(seconds);
+ break;
+ case ClipHandle.End:
+ _cutter.MoveEnd(seconds);
+ break;
+ default:
+ Playhead = Math.Clamp(seconds, 0, Duration);
+ SelectedIndex = _cutter.IndexAt(Playhead);
+ if (SelectedIndices.Count > 0 && !SelectedIndices.Contains(SelectedIndex))
+ SelectedIndices = [];
+ if (SelectedLane == ClipLane.Music)
+ SelectedLane = ClipLane.Video;
+ break;
+ }
+ SchedulePreview();
+ }
+
+ /// Puts the playhead at and selects the piece there (a click on the timeline).
+ public void MovePlayhead(double seconds) => MoveHandle(ClipHandle.Playhead, seconds);
+
+ /// Moves the whole clip, cuts and all, so that it starts at .
+ public void MoveClip(double start)
+ {
+ if (!CanEdit || _cutter is null || !double.IsFinite(start))
+ return;
+ StopPlaying();
+ _cutter.Move(start);
+ SchedulePreview();
+ }
+
+ /// Moves the active handle by (arrow keys: 0.1 s, with Shift 1 s).
+ public void Nudge(double seconds) =>
+ MoveHandle(ActiveHandle, ActiveHandle switch { ClipHandle.Start => Start, ClipHandle.End => End, _ => Playhead } + seconds);
+
+ /// Makes the one the arrow keys move and the large picture shows.
+ public void Activate(ClipHandle handle)
+ {
+ if (!CanEdit || ActiveHandle == handle)
+ return;
+ ActiveHandle = handle;
+ SchedulePreview();
+ }
+
+ [RelayCommand]
+ private void ActivateStart() => Activate(ClipHandle.Start);
+
+ [RelayCommand]
+ private void ActivateEnd() => Activate(ClipHandle.End);
+
+ /// A quick length button: its seconds () as text.
+ [RelayCommand]
+ private void QuickLength(string seconds)
+ {
+ if (double.TryParse(seconds, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ SetLength(value);
+ }
+
+ ///
+ /// The end after the start; when the video ends sooner, its last
+ /// . The end becomes the active handle, to see where the clip now ends.
+ ///
+ public void SetLength(double seconds)
+ {
+ if (!CanEdit || _cutter is null || seconds <= 0)
+ return;
+ StopPlaying();
+ _cutter.SetLength(seconds);
+ ActiveHandle = ClipHandle.End;
+ SchedulePreview();
+ }
+
+ /// The blade (C): cuts the piece at the playhead in two.
+ [RelayCommand]
+ public void Split()
+ {
+ if (!CanEdit || _cutter is null)
+ return;
+ StopPlaying();
+ if (_cutter.Split(Playhead))
+ SelectedIndex = _cutter.IndexAt(Playhead);
+ else
+ ShowMessage(_loc["clip.cannot_cut"]);
+ }
+
+ ///
+ /// Delete, on what is selected: a piece is cut out and leaves a gap (with , Shift+Delete,
+ /// what follows moves up at once); a gap is closed; a piece cut out is put back; on A1 the piece's sound is muted or
+ /// unmuted; on A2 the music is taken away.
+ ///
+ public void Delete(bool closeUp)
+ {
+ if (!CanEdit || _cutter is null)
+ return;
+ StopPlaying();
+ if (IsMusicSelected)
+ {
+ RemoveMusic();
+ return;
+ }
+ var targets = Targets;
+ if (targets.Count == 0)
+ return;
+ var first = Pieces[targets[0]];
+ var refused = false;
+ _cutter.Batch(() =>
+ {
+ foreach (var index in targets)
+ {
+ var piece = _cutter.Pieces[index];
+ if (piece.State == ClipPieceState.Blank)
+ _cutter.CloseGap(index);
+ else if (piece.State == ClipPieceState.Removed)
+ _cutter.Restore(index);
+ else if (SelectedLane == ClipLane.Audio)
+ _cutter.SetMuted(index, !first.Muted);
+ else if (!_cutter.Remove(index, closeUp))
+ refused = true;
+ }
+ });
+ if (refused)
+ ShowMessage(_loc["clip.last_piece"]);
+ OnPropertyChanged(nameof(DeleteText));
+ }
+
+ [RelayCommand]
+ private void DeleteSelected() => Delete(closeUp: false);
+
+ [RelayCommand]
+ public void Undo()
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.Undo();
+ }
+ }
+
+ [RelayCommand]
+ public void Redo()
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.Redo();
+ }
+ }
+
+ /// Closes the pauses left by pieces cut out with the magnet off.
+ [RelayCommand]
+ public void CloseGaps()
+ {
+ if (CanEdit && _cutter is not null)
+ {
+ StopPlaying();
+ _cutter.CloseGaps();
+ }
+ }
+
+ /// Finds the silent stretches of the clip and takes them out (one undo puts them back).
+ [RelayCommand]
+ public async Task RemoveSilenceAsync()
+ {
+ if (!CanEdit || _cutter is null || IsFindingSilence)
+ return;
+ StopPlaying();
+ var cutter = _cutter;
+ var ct = _fileCts.Token;
+ IsFindingSilence = true;
+ try
+ {
+ var silences = await _frames.FindSilencesAsync(_files[CurrentIndex], cutter.Start, cutter.End, ct);
+ if (ct.IsCancellationRequested || _closed || !ReferenceEquals(cutter, _cutter))
+ return;
+ var removed = cutter.RemoveSilences(silences);
+ ShowMessage(removed > 0 ? _loc.Format("clip.silence_removed", removed) : _loc["clip.no_silence"]);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogWarning(ex, "Could not find the silences of {File}", _files[CurrentIndex]);
+ ShowMessage(_loc["clip.no_silence"]);
+ }
+ finally
+ {
+ IsFindingSilence = false;
+ }
+ }
+
+ partial void OnIsFastChanged(bool value)
+ {
+ // Remembered for next time, in the tool as saved (and in the one given to this window, maybe the same).
+ if (_store.FindPreset(_tool.Id) is { IsClipTool: true } saved && saved.Media.ExactCut == value)
+ {
+ saved.Media.ExactCut = !value;
+ _tool.Media.ExactCut = !value;
+ try
+ {
+ _store.SaveLibrary();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _log?.LogWarning(ex, "Could not remember the clip's cut");
+ }
+ }
+ _tool.Media.ExactCut = !value;
+ }
+
+ // ───────────────────────── Playing ─────────────────────────
+
+ /// Space: plays the clip as it will be written, with its sound, from the playhead (or its start); again stops.
+ [RelayCommand]
+ public void PlayPause()
+ {
+ if (IsPlaying)
+ StopPlaying();
+ else
+ _ = PlayAsync();
+ }
+
+ private async Task PlayAsync()
+ {
+ if (!CanEdit || _cutter is null || _closed)
+ return;
+ var edit = _cutter.ToEdit();
+ var from = Playhead > edit.Start && Playhead < edit.End ? edit.ClipTimeAt(Playhead) : 0;
+ if (from >= edit.Length - 0.05)
+ from = 0;
+ _previewCts?.Cancel();
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(_fileCts.Token);
+ _playCts = cts;
+ IsPlaying = true;
+ IsBuffering = true;
+ ActiveHandle = ClipHandle.Playhead;
+ Playhead = edit.SourceTimeAt(from);
+ var path = _files[CurrentIndex];
+ try
+ {
+ await _frames.PlayAsync(path, edit.From(from), PlayHeight,
+ (pixels, width, height, seconds) => ShowFrame(pixels, width, height, edit.SourceTimeAt(from + seconds), cts.Token), cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogWarning(ex, "Could not play the clip of {File}", path);
+ ShowMessage(_loc["clip.cannot_play"]);
+ }
+ finally
+ {
+ if (ReferenceEquals(_playCts, cts))
+ {
+ _playCts = null;
+ IsPlaying = false;
+ IsBuffering = false;
+ if (!_closed && !cts.IsCancellationRequested)
+ SchedulePreview(immediately: true);
+ }
+ cts.Dispose();
+ }
+ }
+
+ /// Stops the clip playing; the playhead stays where it got to.
+ public void StopPlaying()
+ {
+ if (_playCts is not { } cts)
+ return;
+ _playCts = null;
+ cts.Cancel();
+ IsPlaying = false;
+ IsBuffering = false;
+ if (!_closed)
+ SchedulePreview(immediately: true);
+ }
+
+ /// A picture of the clip playing (on a worker thread): written into a frame not on screen, then shown.
+ private void ShowFrame(byte[] pixels, int width, int height, double sourceTime, CancellationToken ct)
+ {
+ if (ct.IsCancellationRequested || _closed)
+ return;
+ WriteableBitmap bitmap;
+ lock (_playFramesLock)
+ {
+ _playFrame = 1 - _playFrame;
+ bitmap = _playFrames[_playFrame] is { } existing && existing.PixelSize == new PixelSize(width, height)
+ ? existing
+ : _playFrames[_playFrame] = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
+ using var buffer = bitmap.Lock();
+ var row = width * 4;
+ for (var y = 0; y < height; y++)
+ Marshal.Copy(pixels, y * row, buffer.Address + y * buffer.RowBytes, row);
+ }
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (ct.IsCancellationRequested || _closed)
+ return;
+ SetPreview(bitmap, own: false);
+ IsBuffering = false;
+ Playhead = sourceTime;
+ });
+ }
+
+ // ───────────────────────── Saving ─────────────────────────
+
+ ///
+ /// Starts the conversion that writes the clip (its progress shows in the toast, where it can be cancelled), then
+ /// opens the next video or closes.
+ ///
+ [RelayCommand]
+ public async Task SaveAsync()
+ {
+ if (!CanEdit || _closed || _cutter is null || CurrentIndex >= _files.Count)
+ return;
+ StopPlaying();
+ var path = _files[CurrentIndex];
+ var tool = _tool.Clone();
+ tool.Media.ExactCut = !IsFast;
+ try
+ {
+ _export(path, ClipTool.ConversionOf(tool, _targetFor(path), _cutter.ToEdit()));
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogError(ex, "Could not start cutting a clip of {File}", path);
+ AddProblem(path, "error.conversion_failed");
+ }
+ await LoadFileAsync(CurrentIndex + 1);
+ }
+
+ /// Leaves this video as it is and opens the next one.
+ [RelayCommand]
+ public async Task SkipAsync()
+ {
+ if (IsFinished || _closed)
+ return;
+ await LoadFileAsync(CurrentIndex + 1);
+ }
+
+ /// Close (Esc). Clips already started go on in the toast.
+ [RelayCommand]
+ public void Cancel()
+ {
+ Shutdown();
+ CloseRequested?.Invoke();
+ }
+
+ // ───────────────────────── The large picture ─────────────────────────
+
+ ///
+ /// Shows the timeline picture nearest the frame looked at at once, and takes that exact frame once the handle rests
+ /// for a moment.
+ ///
+ private void SchedulePreview(bool immediately = false)
+ {
+ if (_closed || _video is null || IsPlaying)
+ return;
+ var nearest = NearestThumbnail(PreviewTime);
+ if (nearest is not null && !ReferenceEquals(nearest, Preview) && !immediately)
+ SetPreview(nearest, own: false);
+
+ _previewCts?.Cancel();
+ _previewCts?.Dispose();
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(_fileCts.Token);
+ _previewCts = cts;
+ _ = TakePreviewAsync(_files[CurrentIndex], PreviewTime, immediately ? TimeSpan.Zero : PreviewDelay, cts.Token);
+ }
+
+ private async Task TakePreviewAsync(string path, double seconds, TimeSpan delay, CancellationToken ct)
+ {
+ try
+ {
+ if (delay > TimeSpan.Zero)
+ await Task.Delay(delay, ct);
+ var output = Path.Combine(_workFolder, $"frame_{++_previewNumber}.jpg");
+ if (!await _frames.WriteFrameAsync(path, seconds, output, PreviewHeight, ct) || ct.IsCancellationRequested || _closed)
+ return;
+ if (Load(output) is { } bitmap)
+ {
+ if (ct.IsCancellationRequested || _closed || IsPlaying)
+ bitmap.Dispose();
+ else
+ SetPreview(bitmap, own: true);
+ }
+ TryDelete(output);
+ }
+ catch (OperationCanceledException)
+ {
+ // The handle moved on.
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogDebug(ex, "No frame at {Seconds} s of {File}", seconds, path);
+ }
+ }
+
+ private Bitmap? NearestThumbnail(double seconds)
+ {
+ if (Thumbnails.Count == 0 || Duration <= 0)
+ return null;
+ var index = Math.Clamp((int)(seconds / Duration * Thumbnails.Count), 0, Thumbnails.Count - 1);
+ // The nearest one that is there already, looking outwards.
+ for (var d = 0; d < Thumbnails.Count; d++)
+ {
+ if (index - d >= 0 && Thumbnails[index - d] is { } before)
+ return before;
+ if (index + d < Thumbnails.Count && Thumbnails[index + d] is { } after)
+ return after;
+ }
+ return null;
+ }
+
+ private void SetPreview(Bitmap bitmap, bool own)
+ {
+ var old = _ownPreview;
+ Preview = bitmap;
+ _ownPreview = own ? bitmap : null;
+ if (old is not null && !ReferenceEquals(old, bitmap))
+ old.Dispose();
+ }
+
+ private static Bitmap? Load(string file)
+ {
+ try
+ {
+ return new Bitmap(file);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ return null;
+ }
+ }
+
+ // ───────────────────────── Files, messages and the end ─────────────────────────
+
+ private void ShowMessage(string text)
+ {
+ Message = text;
+ _ = ClearMessageLaterAsync(text);
+ }
+
+ private async Task ClearMessageLaterAsync(string text)
+ {
+ try
+ {
+ await Task.Delay(MessageTime, _fileCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ if (Message == text)
+ Message = "";
+ }
+
+ private void AddProblem(string path, string key)
+ {
+ var line = $"{Path.GetFileName(path)}: {_loc[key]}";
+ Problems.Add(line);
+ StatusNotice = line;
+ }
+
+ /// Closes after the last file, or stays open on the summary when a file could not be opened.
+ private void Finish()
+ {
+ if (Problems.Count == 0)
+ {
+ Cancel();
+ return;
+ }
+ IsLoading = false;
+ Summary = _loc.Format("toast.done_errors", Problems.Count, _files.Count);
+ StatusNotice = "";
+ HasMultipleFiles = false;
+ ClearPictures(keepCount: false);
+ IsFinished = true;
+ }
+
+ private void ClearPictures(bool keepCount = true)
+ {
+ _video = null;
+ var own = _ownPreview;
+ _ownPreview = null;
+ Preview = null;
+ own?.Dispose();
+ var old = Thumbnails.ToList();
+ Thumbnails.Clear();
+ if (keepCount)
+ {
+ for (var i = 0; i < ThumbnailCount; i++)
+ Thumbnails.Add(null);
+ }
+ foreach (var bitmap in old)
+ bitmap?.Dispose();
+ }
+
+ private void Shutdown()
+ {
+ if (_closed)
+ return;
+ _closed = true;
+ _playCts?.Cancel();
+ _detailCts?.Cancel();
+ _fileCts.Cancel();
+ _previewCts?.Cancel();
+ }
+
+ private void OnLanguageChanged(object? sender, EventArgs e)
+ {
+ if (!IsFinished && CurrentIndex < _files.Count)
+ {
+ CounterText = _loc.Format("crop.page_counter", CurrentIndex + 1, _files.Count);
+ SaveText = _loc[CurrentIndex < _files.Count - 1 ? "clip.save_next" : "clip.save"];
+ }
+ OnPropertyChanged(nameof(PreviewLabel));
+ OnPropertyChanged(nameof(Length15Text));
+ OnPropertyChanged(nameof(Length30Text));
+ OnPropertyChanged(nameof(Length60Text));
+ OnPropertyChanged(nameof(PlayToolTip));
+ OnPropertyChanged(nameof(FastToolTip));
+ OnPropertyChanged(nameof(DeleteText));
+ OnPropertyChanged(nameof(BusyText));
+ OnPropertyChanged(nameof(SelectionText));
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // The work folder goes when the window closes.
+ }
+ }
+
+ public void Dispose()
+ {
+ Shutdown();
+ _loc.LanguageChanged -= OnLanguageChanged;
+ UseCutter(null);
+ ClearPictures(keepCount: false);
+ lock (_playFramesLock)
+ {
+ foreach (var frame in _playFrames)
+ frame?.Dispose();
+ Array.Clear(_playFrames);
+ }
+ _detailCts?.Dispose();
+ foreach (var picture in DetailThumbnails)
+ picture?.Dispose();
+ DetailThumbnails = [];
+ _previewCts?.Dispose();
+ _fileCts.Dispose();
+ // FFmpeg may still hold a picture for a moment after it was cancelled: the folder goes a little later.
+ var folder = _workFolder;
+ _ = Task.Run(async () =>
+ {
+ for (var attempt = 0; attempt < 5 && Directory.Exists(folder); attempt++)
+ {
+ try
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1));
+ }
+ }
+ });
+ }
+}
diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
index 5dbd30aeb..077ff0400 100644
--- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs
+++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
@@ -539,7 +539,8 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne
private string TargetId => Target.Value;
private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category;
private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image;
- private bool TargetIsVideo => TargetCategory == FormatCategory.Video;
+ /// A video target, or "Cut a clip", whose exact cuts are encoded with the video options.
+ private bool TargetIsVideo => TargetCategory == FormatCategory.Video || _original.IsClipTool;
private bool TargetIsAudio => TargetCategory == FormatCategory.Audio;
private bool TargetIsLossyMedia => TargetIsVideo
|| (TargetIsAudio && !LosslessAudioTargets.Contains(TargetId) && MediaCodecs.Effective(TargetId, _audioCodecValue) != AudioCodec.Alac);
diff --git a/src/Filee.App/Views/ClipWindow.axaml b/src/Filee.App/Views/ClipWindow.axaml
new file mode 100644
index 000000000..d72f57589
--- /dev/null
+++ b/src/Filee.App/Views/ClipWindow.axaml
@@ -0,0 +1,506 @@
+
+
+
+
+ M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3z M7 6.2a.8.8 0 0 0-.8.8v10c0 .44.36.8.8.8h10a.8.8 0 0 0 .8-.8V7a.8.8 0 0 0-.8-.8z
+ M18 10a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M18 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M12 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z
+ M13.2 2.2 4.6 13.4h6.1l-1.5 8.4 8.6-11.2h-6.1z
+ M8 5.5v13l11-6.5z
+ M6.5 5h4v14h-4z M13.5 5h4v14h-4z
+ M12.5 8c-2.65 0-5.05 1-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z
+ M18.4 10.6C16.55 9 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7z
+ M3,7V13A9,9 0 0,0 12,22A9,9 0 0,0 21,13V7H17V13A5,5 0 0,1 12,18A5,5 0 0,1 7,13V7M17,5H21V2H17M3,5H7V2H3
+ M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.5,8.71 14,7.97V16C15.5,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z
+ M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z
+ M2 20h20V4z
+ M2 4v16h20z
+ M2 11h2v2H2z M5.5 8h2v8h-2z M9 5h2v14H9z M17 8h2v8h-2z M20 11h2v2h-2z M12.6 11.3h2.8v1.4h-2.8z
+ M12,3V12.26C11.5,12.09 11,12 10.5,12C8,12 6,14 6,16.5C6,19 8,21 10.5,21C13,21 15,19 15,16.5V6H19V3H12Z
+ M12,4L9.91,6.09L12,8.18M4.27,3L3,4.27L7.73,9H3V15H7L12,20V13.27L16.25,17.53C15.58,18.04 14.83,18.46 14,18.7V20.77C15.38,20.45 16.63,19.82 17.68,18.96L19.73,21L21,19.73L12,10.73M19,12C19,12.94 18.8,13.82 18.46,14.64L19.97,16.15C20.62,14.91 21,13.5 21,12C21,7.72 18,4.14 14,3.23V5.29C16.89,6.15 19,8.83 19,12M16.5,12C16.5,10.23 15.5,8.71 14,7.97V10.18L16.45,12.63C16.5,12.43 16.5,12.21 16.5,12Z
+ M13,20V4H15.03V20H13M10,20V4H12.03V20H10M5,8L9.03,12L5,16V13H2V11H5V8M20,16L16,12L20,8V11H23V13H20V16Z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/ClipWindow.axaml.cs b/src/Filee.App/Views/ClipWindow.axaml.cs
new file mode 100644
index 000000000..26c933f37
--- /dev/null
+++ b/src/Filee.App/Views/ClipWindow.axaml.cs
@@ -0,0 +1,266 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+
+namespace Filee.App.Views;
+
+///
+/// The "Cut a clip" window: placement, keys (Space plays, C cuts at the playhead, Delete cuts a piece out or puts it
+/// back, Ctrl+Z / Ctrl+Y undo and redo, M the magnet, Z zooms the timeline in on the clip, S / E pick the start or end handle, P the playhead, the arrow keys
+/// move it by 0.1 s, with Shift by 1 s, Enter saves, Esc stops playing or closes), the timeline's drags and moving,
+/// sizing and maximizing the frameless window.
+///
+public partial class ClipWindow : Window
+{
+ /// How far the arrow keys move a handle, in seconds; with Shift .
+ public const double SmallStep = 0.1, LargeStep = 1;
+
+ private ClipWindowViewModel? _vm;
+
+ /// The focus came from the keyboard (Tab, arrows), not from a click.
+ private bool _focusByKeyboard;
+
+ public ClipWindow()
+ {
+ InitializeComponent();
+ Motion.Track(this);
+ DataContextChanged += OnDataContextChanged;
+ AddHandler(GotFocusEvent, (_, e) => _focusByKeyboard = e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional,
+ RoutingStrategies.Bubble, handledEventsToo: true);
+ AddHandler(KeyDownEvent, OnKeyDownFirst, RoutingStrategies.Tunnel);
+ // Space and Enter are the window's own (play, save), so a button that was clicked can't be pressed by them
+ // later; their key-up must not press it either. A button reached with Tab is pressed by them as usual.
+ AddHandler(KeyUpEvent, (_, e) =>
+ {
+ if (e.Key is Key.Space or Key.Enter && !ControlTakes(e))
+ e.Handled = true;
+ }, RoutingStrategies.Tunnel);
+ ResizeGrip.PointerPressed += OnGripPressed;
+ }
+
+ internal ClipTimeline ClipTimeline => Timeline;
+ internal ClipSequence ClipSequence => Sequence;
+
+ /// Music…: picks a sound (or video) file and puts its sound under the clip.
+ private async void OnAddMusic(object? sender, RoutedEventArgs e)
+ {
+ if (_vm is not { } vm)
+ return;
+ try
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions
+ {
+ Title = vm.MusicPickerTitle,
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new Avalonia.Platform.Storage.FilePickerFileType(vm.MusicFilesName)
+ {
+ Patterns = ["*.mp3", "*.m4a", "*.aac", "*.wav", "*.flac", "*.ogg", "*.opus", "*.wma", "*.mp4", "*.mkv", "*.mov", "*.webm"],
+ },
+ Avalonia.Platform.Storage.FilePickerFileTypes.All,
+ ],
+ });
+ if (files.Count > 0 && files[0].TryGetLocalPath() is { } path)
+ await vm.AddMusicAsync(path);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // The picker isn't there (a test, an unusual desktop): nothing to add.
+ System.Diagnostics.Debug.WriteLine(ex);
+ }
+ }
+
+ ///
+ /// Opens near (the screen of the donut), else on the primary screen, centred in its working
+ /// area and no larger than most of it.
+ ///
+ public void PositionOnScreen(PixelPoint? near = null)
+ {
+ var screen = (near.HasValue ? Screens.ScreenFromPoint(near.Value) : null)
+ ?? Screens.ScreenFromPoint(Screens.Primary?.Bounds.Center ?? default)
+ ?? Screens.Primary;
+ if (screen is null)
+ return;
+ var scaling = screen.Scaling;
+ var area = screen.WorkingArea;
+ Width = Math.Max(MinWidth, Math.Min(Width, area.Width / scaling * 0.94));
+ Height = Math.Max(MinHeight, Math.Min(Height, area.Height / scaling * 0.92));
+ var x = area.X + Math.Max(0, (area.Width - (int)(Width * scaling)) / 2);
+ var y = area.Y + Math.Max(0, (area.Height - (int)(Height * scaling)) / 2);
+ Position = new PixelPoint(x, y);
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (DataContext is ClipWindowViewModel vm && !ReferenceEquals(vm, _vm))
+ {
+ _vm = vm;
+ vm.CloseRequested += Close;
+ Timeline.HandleMoved += vm.MoveHandle;
+ Timeline.ClipMoved += vm.MoveClip;
+ Timeline.PlayheadMoved += vm.MovePlayhead;
+ Sequence.PlayheadMoved += vm.MoveSequencePlayhead;
+ Sequence.PieceSelected += vm.SelectPiece;
+ Sequence.MarqueeSelected += vm.SelectAcross;
+ Sequence.EdgeDragStarted += vm.BeginTrim;
+ Sequence.EdgeDragged += vm.Trim;
+ Sequence.GapCloseRequested += vm.CloseGap;
+ Sequence.VolumeDragged += vm.SetPieceVolume;
+ Sequence.MusicMoved += vm.MoveMusic;
+ Sequence.MusicVolumeDragged += vm.SetMusicVolume;
+ Closed += (_, _) => vm.Dispose();
+ }
+ }
+
+ // ───────────────────────── Keys ─────────────────────────
+
+ ///
+ /// The control that has the focus, reached with the keyboard, takes 's key as it would anywhere
+ /// else: Space and Enter press a button, a toggle or a switch; the arrows move a slider or a list.
+ ///
+ private bool ControlTakes(KeyEventArgs e)
+ {
+ if (!_focusByKeyboard || e.KeyModifiers != KeyModifiers.None)
+ return false;
+ return FocusManager?.GetFocusedElement() switch
+ {
+ Button => e.Key is Key.Space or Key.Enter,
+ Slider => e.Key is Key.Left or Key.Right or Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp or Key.PageDown,
+ ComboBox => e.Key is Key.Space or Key.Enter or Key.Up or Key.Down,
+ _ => false,
+ };
+ }
+
+ ///
+ /// Before any focused control: Space plays and Enter saves, unless a control was reached with Tab (then its keys are
+ /// its own, as everywhere else).
+ ///
+ private void OnKeyDownFirst(object? sender, KeyEventArgs e)
+ {
+ if (_vm is not { } vm)
+ return;
+ if (ControlTakes(e))
+ return;
+ var plain = e.KeyModifiers == KeyModifiers.None;
+ var command = e.KeyModifiers == KeyModifiers.Control || e.KeyModifiers == KeyModifiers.Meta;
+ var commandShift = e.KeyModifiers == (KeyModifiers.Control | KeyModifiers.Shift) || e.KeyModifiers == (KeyModifiers.Meta | KeyModifiers.Shift);
+ switch (e.Key)
+ {
+ case Key.Escape:
+ // Stops playing first, then drops a selection of several pieces, then closes.
+ if (vm.IsPlaying)
+ vm.StopPlaying();
+ else if (!vm.CollapseSelection())
+ vm.Cancel();
+ e.Handled = true;
+ break;
+ case Key.Space:
+ // Always play / pause, whatever was clicked last.
+ if (plain && vm.CanEdit)
+ vm.PlayPause();
+ e.Handled = true;
+ break;
+ case Key.A when command && vm.CanEdit:
+ vm.SelectAll();
+ e.Handled = true;
+ break;
+ case Key.C when plain && vm.CanEdit:
+ vm.Split();
+ e.Handled = true;
+ break;
+ case Key.Delete or Key.Back when (plain || e.KeyModifiers == KeyModifiers.Shift) && vm.CanEdit:
+ // Shift+Delete: cut out and close up at once (a ripple delete).
+ vm.Delete(closeUp: e.KeyModifiers == KeyModifiers.Shift);
+ e.Handled = true;
+ break;
+ case Key.Z when command && vm.CanEdit:
+ vm.Undo();
+ e.Handled = true;
+ break;
+ case Key.Y when command && vm.CanEdit:
+ case Key.Z when commandShift && vm.CanEdit:
+ vm.Redo();
+ e.Handled = true;
+ break;
+ case Key.M when plain && vm.CanEdit:
+ vm.IsMagnet = !vm.IsMagnet;
+ e.Handled = true;
+ break;
+ case Key.Z when plain && vm.CanEdit:
+ vm.ToggleZoom();
+ e.Handled = true;
+ break;
+ case Key.P when plain && vm.CanEdit:
+ vm.Activate(ClipHandle.Playhead);
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ if (vm.IsFinished)
+ vm.Cancel();
+ else
+ _ = vm.SaveAsync();
+ e.Handled = true;
+ break;
+ case Key.S when plain && vm.CanEdit:
+ vm.Activate(ClipHandle.Start);
+ e.Handled = true;
+ break;
+ case Key.E when plain && vm.CanEdit:
+ vm.Activate(ClipHandle.End);
+ e.Handled = true;
+ break;
+ // Time runs left to right on the timeline, also in right-to-left languages: Left is always earlier.
+ case Key.Left or Key.Right when vm.CanEdit && (e.KeyModifiers & ~KeyModifiers.Shift) == KeyModifiers.None:
+ var step = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? LargeStep : SmallStep;
+ vm.Nudge(e.Key == Key.Left ? -step : step);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ // ───────────────────────── Moving and sizing the window ─────────────────────────
+
+ /// The stage beside the picture moves the window; a double click maximizes it.
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ if (e.ClickCount == 2)
+ ToggleMaximized();
+ else if (WindowState == WindowState.Normal)
+ BeginMoveDrag(e);
+ }
+
+ private void OnGripPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (WindowState == WindowState.Normal && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginResizeDrag(WindowEdge.SouthEast, e);
+ e.Handled = true;
+ }
+
+ private void OnMaximize(object? sender, RoutedEventArgs e) => ToggleMaximized();
+
+ private void ToggleMaximized() =>
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == WindowStateProperty)
+ {
+ var maximized = WindowState == WindowState.Maximized;
+ Classes.Set("maximized", maximized);
+ ResizeGrip.IsVisible = !maximized;
+ var key = maximized ? "crop.restore" : "crop.maximize";
+ MaximizeButton.Bind(ToolTip.TipProperty, MaximizeButton.GetResourceObservable(key));
+ MaximizeButton.Bind(Avalonia.Automation.AutomationProperties.NameProperty, MaximizeButton.GetResourceObservable(key));
+ }
+ }
+}
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index 07d582944..192f5966b 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -82,6 +82,7 @@ .. PdfSlidesPresets(),
.. FramePresets(),
.. MotionPhotoPresets(),
.. LivePhotoPresets(),
+ .. ClipPresets(),
.. MediaToolPresets(),
new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" },
new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" },
@@ -119,6 +120,19 @@ public static List LivePhotoPresets() =>
new() { Id = "motion-photo-android", NameKey = "preset.motion_photo_android", TargetFormat = "jpg", Media = { LivePhoto = LivePhotoKind.Android } },
];
+ ///
+ /// "Cut a clip" (added in settings schema 45): a part of a video, chosen on a timeline, as a file of its own. First on
+ /// the back of the video donut, as "Crop" is on the images donut.
+ ///
+ public static List ClipPresets() =>
+ [
+ new()
+ {
+ Id = "tool-clip", NameKey = "preset.clip", TargetFormat = SameAsSource,
+ Kind = PresetKind.Tool, Tool = Preset.ClipTool, Output = { FileNamePattern = "{name}_clip" },
+ },
+ ];
+
///
/// "Trim transparent borders" (issue #332, added in settings schema 33): a copy cropped to the visible pixels, in the
/// same format. On the images donut's back face after "Remove background".
@@ -551,9 +565,10 @@ public static List CatalogProfiles() =>
Extensions = ExtensionsOf(FormatCategory.Video),
PresetIds = ["to-mp4", "mp4-720", "mp4-hevc", "to-webm", "to-mov", "to-gif", "to-mp3", "extract-subtitles", "to-mkv", "to-avi"],
// Every frame as pictures is on no front (a long video gives thousands of files): behind a flip, a drop that
- // misses its slice doesn't start it. Live / Motion Photos of a clip are there too.
- BackPresetIds = ["mp4-av1", "to-webp", "extract-frames-png", "extract-frames-jpg", "live-photo-iphone", "motion-photo-android",
- "extract-audio", "to-wav", "to-flac", "compress-video", "merge-videos", "zip-all"],
+ // misses its slice doesn't start it. Live / Motion Photos of a clip are there too, and "Cut a clip" first
+ // (in place of FLAC, which stays in the library).
+ BackPresetIds = ["tool-clip", "mp4-av1", "to-webp", "extract-frames-png", "extract-frames-jpg", "live-photo-iphone", "motion-photo-android",
+ "extract-audio", "to-wav", "compress-video", "merge-videos", "zip-all"],
},
new()
{
diff --git a/src/Filee.Core/Presets/ClipCutter.cs b/src/Filee.Core/Presets/ClipCutter.cs
new file mode 100644
index 000000000..28f54835d
--- /dev/null
+++ b/src/Filee.Core/Presets/ClipCutter.cs
@@ -0,0 +1,630 @@
+// The clip window's editing, without the window: the stretch of the video between the start and end handles, cut into
+// pieces with the blade. A piece can be cut out (leaving a pause that can be closed, or closing up at once), put back,
+// trimmed by its edges (taking back what was cut next to it), made louder or quieter, muted, faster or slower; silent
+// stretches can be taken out at once; music from another file can go under the clip. Every change can be undone.
+// ToEdit() gives what the conversion writes.
+
+namespace Filee.Core.Presets;
+
+/// What happens to a piece of the clip.
+public enum ClipPieceState
+{
+ /// It plays.
+ Kept,
+
+ /// Cut out, and what follows closed up.
+ Removed,
+
+ /// Cut out, its time left as a pause of black and silence (until the gap is closed).
+ Blank,
+}
+
+/// A piece of the clip between two cuts, in seconds of the video.
+public sealed record ClipPiece(double Start, double End, ClipPieceState State = ClipPieceState.Kept, double Volume = 1, double Speed = 1,
+ bool Muted = false)
+{
+ public bool IsKept => State == ClipPieceState.Kept;
+
+ /// How long it plays in the clip (nothing when removed).
+ public double Length => State == ClipPieceState.Removed ? 0 : Math.Max(0, End - Start) / Speed;
+}
+
+/// Cuts a stretch of a video into pieces and changes them; see the file comment.
+public sealed class ClipCutter
+{
+ /// Changes that can be undone.
+ private const int UndoDepth = 100;
+
+ /// A silent stretch shorter than this is a pause in speech, not a gap: it stays.
+ public const double MinSilence = 0.4;
+
+ /// Kept of a silence on either side next to sound, so words aren't clipped.
+ public const double SilencePadding = 0.12;
+
+ private sealed record State(List Pieces, double FadeIn, double FadeOut, ClipMusic? Music);
+
+ private List _pieces;
+ private double _fadeIn, _fadeOut;
+ private ClipMusic? _music;
+ private readonly Stack _undo = new();
+ private readonly Stack _redo = new();
+
+ /// What the last change did, when changes alike join into one undo step (a slider or an edge dragged).
+ private string? _joining;
+
+ /// Inside : changes are made but remembered (and told) once, at its end.
+ private int _batching;
+
+ /// The video's length in seconds.
+ /// The clip to start with, kept inside the video.
+ public ClipCutter(double duration, ClipRange range)
+ {
+ Duration = Math.Max(ClipRange.MinLength, duration);
+ var clip = ClipRange.Clamp(range.Start, range.End, Duration);
+ _pieces = [new ClipPiece(clip.Start, clip.End)];
+ }
+
+ /// Raised after every change (also undo and redo).
+ public event Action? Changed;
+
+ public double Duration { get; }
+
+ /// The pieces from the start to the end handle, one after the other without gaps.
+ public IReadOnlyList Pieces => _pieces;
+
+ public double Start => _pieces[0].Start;
+ public double End => _pieces[^1].End;
+
+ /// Seconds the clip fades in (0 for none).
+ public double FadeIn
+ {
+ get => _fadeIn;
+ set => Change(() => _fadeIn = ClipEdit.Fade(value), _fadeIn != ClipEdit.Fade(value));
+ }
+
+ /// Seconds the clip fades out (0 for none).
+ public double FadeOut
+ {
+ get => _fadeOut;
+ set => Change(() => _fadeOut = ClipEdit.Fade(value), _fadeOut != ClipEdit.Fade(value));
+ }
+
+ /// The music under the clip (a copy), or null.
+ public ClipMusic? Music => _music?.Clone();
+
+ /// How long the clip plays, in seconds.
+ public double Length => _pieces.Sum(p => p.Length);
+
+ /// True when pieces were cut out leaving a pause.
+ public bool HasGaps => _pieces.Any(p => p.State == ClipPieceState.Blank);
+
+ public bool CanUndo => _undo.Count > 0;
+ public bool CanRedo => _redo.Count > 0;
+
+ /// The piece at of the video, or -1 outside the clip.
+ public int IndexAt(double seconds)
+ {
+ if (seconds < Start || seconds > End)
+ return -1;
+ for (var i = 0; i < _pieces.Count; i++)
+ {
+ if (seconds < _pieces[i].End || i == _pieces.Count - 1)
+ return i;
+ }
+ return -1;
+ }
+
+ // ───────────────────────── The ends ─────────────────────────
+
+ ///
+ /// Moves the start to . Past the end it takes the clip along, cuts and all, as far as the
+ /// video goes.
+ ///
+ public void MoveStart(double seconds)
+ {
+ if (!double.IsFinite(seconds))
+ return;
+ var start = Math.Clamp(seconds, 0, Duration - ClipRange.MinLength);
+ if (start == Start)
+ return;
+ Change(() =>
+ {
+ if (start > End - ClipRange.MinLength)
+ {
+ Shift(start - Start);
+ Bound(Start, Math.Min(End, Duration));
+ }
+ else
+ Bound(start, End);
+ });
+ }
+
+ /// Moves the end to ; before the start it takes the clip along.
+ public void MoveEnd(double seconds)
+ {
+ if (!double.IsFinite(seconds))
+ return;
+ var end = Math.Clamp(seconds, ClipRange.MinLength, Duration);
+ if (end == End)
+ return;
+ Change(() =>
+ {
+ if (end < Start + ClipRange.MinLength)
+ {
+ Shift(end - End);
+ Bound(Math.Max(0, Start), End);
+ }
+ else
+ Bound(Start, end);
+ });
+ }
+
+ /// Moves the whole clip, cuts and all, to start at (inside the video).
+ public void Move(double seconds)
+ {
+ if (!double.IsFinite(seconds))
+ return;
+ var start = Math.Clamp(seconds, 0, Math.Max(0, Duration - (End - Start)));
+ if (start != Start)
+ Change(() => Shift(start - Start));
+ }
+
+ ///
+ /// The end after the start (of the video, cuts included); when the video ends sooner,
+ /// its last .
+ ///
+ public void SetLength(double seconds)
+ {
+ if (!double.IsFinite(seconds) || seconds <= 0)
+ return;
+ var length = Math.Clamp(seconds, ClipRange.MinLength, Duration);
+ var start = Math.Min(Start, Duration - length);
+ Change(() => Bound(start, start + length));
+ }
+
+ // ───────────────────────── Cuts ─────────────────────────
+
+ ///
+ /// The blade: cuts the piece at in two (both keep its volume, speed and mute). False
+ /// when it is outside the clip or too close to a cut.
+ ///
+ public bool Split(double seconds)
+ {
+ var index = IndexAt(seconds);
+ if (index < 0)
+ return false;
+ var piece = _pieces[index];
+ if (seconds - piece.Start < ClipRange.MinLength || piece.End - seconds < ClipRange.MinLength)
+ return false;
+ Change(() =>
+ {
+ _pieces[index] = piece with { End = seconds };
+ _pieces.Insert(index + 1, piece with { Start = seconds });
+ });
+ return true;
+ }
+
+ ///
+ /// Cuts piece out: with what follows closes up (a ripple
+ /// delete), otherwise its time stays as a pause until the gap is closed. False for the last piece that plays.
+ ///
+ public bool Remove(int index, bool closeUp)
+ {
+ if (index < 0 || index >= _pieces.Count)
+ return false;
+ var state = closeUp ? ClipPieceState.Removed : ClipPieceState.Blank;
+ var piece = _pieces[index];
+ if (piece.State == state || piece.IsKept && _pieces.Count(p => p.IsKept) == 1)
+ return false;
+ Change(() => _pieces[index] = piece with { State = state });
+ return true;
+ }
+
+ /// Puts a piece that was cut out back.
+ public bool Restore(int index)
+ {
+ if (index < 0 || index >= _pieces.Count || _pieces[index].IsKept)
+ return false;
+ Change(() => _pieces[index] = _pieces[index] with { State = ClipPieceState.Kept });
+ return true;
+ }
+
+ /// Closes the gap a piece cut out left (piece , a pause): what follows closes up.
+ public bool CloseGap(int index)
+ {
+ if (index < 0 || index >= _pieces.Count || _pieces[index].State != ClipPieceState.Blank)
+ return false;
+ Change(() => _pieces[index] = _pieces[index] with { State = ClipPieceState.Removed });
+ return true;
+ }
+
+ /// Closes every gap pieces cut out left. Returns how many.
+ public int CloseGaps()
+ {
+ var gaps = _pieces.Count(p => p.State == ClipPieceState.Blank);
+ if (gaps > 0)
+ Change(() => _pieces = _pieces.Select(p => p.State == ClipPieceState.Blank ? p with { State = ClipPieceState.Removed } : p).ToList());
+ return gaps;
+ }
+
+ ///
+ /// Drags an edge of piece (its end with , else its start) to
+ /// of the video, as a trim on the timeline: outwards it takes back what was cut out next
+ /// to it (pieces cut out are used up as it goes) or, at the clip's ends, more of the video; inwards it shortens the
+ /// piece and what it gives up is cut out. Next to a piece that plays, the cut between them moves. A drag is one
+ /// undo step. Returns where the edge ended up.
+ ///
+ public double TrimEdge(int index, bool end, double seconds)
+ {
+ if (index < 0 || index >= _pieces.Count || !double.IsFinite(seconds))
+ return double.NaN;
+ var join = $"trim {index} {end}";
+ if (end)
+ {
+ if (index == _pieces.Count - 1)
+ {
+ var to = Math.Clamp(seconds, _pieces[index].Start + ClipRange.MinLength, Duration);
+ if (to != End)
+ Change(() => Bound(Start, to), join: join);
+ return End;
+ }
+ Change(() => MoveCut(index, seconds, growing: true), join: join);
+ return _pieces[Math.Min(index, _pieces.Count - 1)].End;
+ }
+ if (index == 0)
+ {
+ var from = Math.Clamp(seconds, 0, _pieces[0].End - ClipRange.MinLength);
+ if (from != Start)
+ Change(() => Bound(from, End), join: join);
+ return Start;
+ }
+ Change(() => MoveCut(index - 1, seconds, growing: false), join: join);
+ return _pieces[Math.Min(index, _pieces.Count - 1)].Start;
+ }
+
+ /// The volume of piece (1 as recorded, 0 silent).
+ public void SetVolume(int index, double volume)
+ {
+ if (index < 0 || index >= _pieces.Count || !double.IsFinite(volume))
+ return;
+ var value = Math.Clamp(Math.Round(volume, 2), 0, ClipPart.MaxVolume);
+ if (_pieces[index].Volume != value)
+ Change(() => _pieces[index] = _pieces[index] with { Volume = value }, join: $"volume {index}");
+ }
+
+ /// Mutes (or unmutes) the sound of piece ; its picture plays on.
+ public void SetMuted(int index, bool muted)
+ {
+ if (index >= 0 && index < _pieces.Count && _pieces[index].Muted != muted)
+ Change(() => _pieces[index] = _pieces[index] with { Muted = muted });
+ }
+
+ /// The speed of piece (1 as recorded).
+ public void SetSpeed(int index, double speed)
+ {
+ if (index < 0 || index >= _pieces.Count || !double.IsFinite(speed) || speed <= 0)
+ return;
+ var value = Math.Clamp(speed, ClipPart.MinSpeed, ClipPart.MaxSpeed);
+ if (_pieces[index].Speed != value)
+ Change(() => _pieces[index] = _pieces[index] with { Speed = value });
+ }
+
+ // ───────────────────────── Music ─────────────────────────
+
+ /// Music under the clip (null takes it away): from the clip's start, at its usual volume, unless given.
+ public void SetMusic(ClipMusic? music) => Change(() => _music = music?.Clone());
+
+ /// Moves the music to start seconds into the clip (a drag is one undo step).
+ public void MoveMusic(double offset)
+ {
+ if (_music is null || !double.IsFinite(offset))
+ return;
+ var value = Math.Clamp(offset, 0, Math.Max(0, Length - ClipRange.MinLength));
+ if (value != _music.Offset)
+ Change(() => _music = WithMusic(m => m.Offset = value), join: "music offset");
+ }
+
+ /// The music's volume (0 to 2; a drag is one undo step).
+ public void SetMusicVolume(double volume)
+ {
+ if (_music is null || !double.IsFinite(volume))
+ return;
+ var value = Math.Clamp(Math.Round(volume, 2), 0, 2);
+ if (value != _music.Volume)
+ Change(() => _music = WithMusic(m => m.Volume = value), join: "music volume");
+ }
+
+ private ClipMusic WithMusic(Action change)
+ {
+ var copy = _music!.Clone();
+ change(copy);
+ return copy;
+ }
+
+ ///
+ /// Takes the silent stretches of the video out of the clip (, from FFmpeg's
+ /// silencedetect): each one at least long, less next to sound,
+ /// is cut out and the rest closes up. Something always stays. Returns how many stretches were taken out; one undo
+ /// puts them all back.
+ ///
+ public int RemoveSilences(IEnumerable silences)
+ {
+ var stretches = new List<(double Start, double End)>();
+ foreach (var silence in silences.OrderBy(s => s.Start))
+ {
+ var start = silence.Start <= Start + 1e-6 ? Start : silence.Start + SilencePadding;
+ var end = silence.End >= End - 1e-6 ? End : silence.End - SilencePadding;
+ start = Math.Max(start, Start);
+ end = Math.Min(end, End);
+ if (end - start >= Math.Max(MinSilence - 2 * SilencePadding, ClipRange.MinLength))
+ stretches.Add((start, end));
+ }
+ if (stretches.Count == 0)
+ return 0;
+
+ var before = Snapshot();
+ var pieces = _pieces;
+ var removed = 0;
+ foreach (var (start, end) in stretches)
+ {
+ pieces = Cut(pieces, start);
+ pieces = Cut(pieces, end);
+ var next = pieces.Select(p => p.IsKept && p.Start >= start - 1e-6 && p.End <= end + 1e-6
+ ? p with { State = ClipPieceState.Removed } : p).ToList();
+ if (next.Any(p => p.IsKept))
+ {
+ removed++;
+ pieces = next;
+ }
+ }
+ if (removed == 0)
+ return 0;
+ Remember(before);
+ _joining = null;
+ _pieces = pieces;
+ Changed?.Invoke();
+ return removed;
+ }
+
+ ///
+ /// Several changes as one: one undo step and one (cutting out several selected pieces,
+ /// setting their volume). Nothing is remembered when nothing changed.
+ ///
+ /// Batches in a row with the same value are one undo step (a slider dragged over several pieces).
+ public void Batch(Action changes, string? join = null)
+ {
+ var before = Snapshot();
+ _batching++;
+ try
+ {
+ changes();
+ }
+ finally
+ {
+ _batching--;
+ }
+ if (_batching > 0 || Same(before))
+ return;
+ if (join is null || join != _joining || _undo.Count == 0)
+ Remember(before);
+ else
+ _redo.Clear();
+ _joining = join;
+ Changed?.Invoke();
+ }
+
+ private bool Same(State state) =>
+ state.Pieces.SequenceEqual(_pieces) && state.FadeIn == _fadeIn && state.FadeOut == _fadeOut
+ && (state.Music is null ? _music is null
+ : _music is not null && (state.Music.Path, state.Music.Offset, state.Music.Volume, state.Music.From)
+ == (_music.Path, _music.Offset, _music.Volume, _music.From));
+
+ /// Takes back the last change.
+ public bool Undo() => Step(_undo, _redo);
+
+ /// Does the last undone change again.
+ public bool Redo() => Step(_redo, _undo);
+
+ ///
+ /// What the conversion writes: the pieces that play (neighbours alike joined), the pauses, the music and the fades.
+ ///
+ /// Every piece is cut out.
+ public ClipEdit ToEdit()
+ {
+ var edit = new ClipEdit { FadeIn = FadeIn, FadeOut = FadeOut, Music = _music?.Clone() };
+ foreach (var piece in _pieces.Where(p => p.State != ClipPieceState.Removed))
+ {
+ var blank = piece.State == ClipPieceState.Blank;
+ if (edit.Parts.Count > 0 && edit.Parts[^1] is var last && !blank && !last.Blank
+ && Math.Abs(last.End - piece.Start) < 1e-9 && last.Volume == piece.Volume && last.Speed == piece.Speed
+ && last.Muted == piece.Muted)
+ {
+ last.End = piece.End;
+ continue;
+ }
+ edit.Parts.Add(new ClipPart
+ {
+ Start = piece.Start,
+ End = piece.End,
+ Blank = blank,
+ Volume = piece.Volume,
+ Speed = piece.Speed,
+ Muted = piece.Muted,
+ });
+ }
+ if (!edit.Parts.Any(p => !p.Blank))
+ throw new InvalidOperationException("Every piece of the clip is cut out.");
+ return edit;
+ }
+
+ // ───────────────────────── Inside ─────────────────────────
+
+ private State Snapshot() => new([.. _pieces], _fadeIn, _fadeOut, _music?.Clone());
+
+ private void Remember(State state)
+ {
+ _undo.Push(state);
+ _redo.Clear();
+ if (_undo.Count > UndoDepth)
+ {
+ var kept = _undo.Take(UndoDepth).Reverse().ToList();
+ _undo.Clear();
+ foreach (var item in kept)
+ _undo.Push(item);
+ }
+ }
+
+ ///
+ /// Changes in a row with the same value are one undo step: dragging a volume slider is undone at once.
+ ///
+ private void Change(Action change, bool changes = true, string? join = null)
+ {
+ if (!changes)
+ return;
+ if (_batching > 0)
+ {
+ change();
+ return;
+ }
+ var before = Snapshot();
+ change();
+ if (join is null || join != _joining || _undo.Count == 0)
+ Remember(before);
+ else
+ _redo.Clear();
+ _joining = join;
+ Changed?.Invoke();
+ }
+
+ private bool Step(Stack from, Stack to)
+ {
+ if (from.Count == 0)
+ return false;
+ to.Push(Snapshot());
+ var state = from.Pop();
+ (_pieces, _fadeIn, _fadeOut, _music) = (state.Pieces, state.FadeIn, state.FadeOut, state.Music);
+ _joining = null;
+ Changed?.Invoke();
+ return true;
+ }
+
+ private void Shift(double delta) =>
+ _pieces = _pieces.Select(p => p with { Start = p.Start + delta, End = p.End + delta }).ToList();
+
+ ///
+ /// Moves the cut after piece to . The piece being dragged (the
+ /// left one when , else the right one) grows over pieces cut out next to it, using them up;
+ /// a piece that plays next to it gives way down to . Dragged inwards, what the
+ /// dragged piece gives up becomes a piece cut out (removed), unless its neighbour plays: then the cut just moves.
+ ///
+ private void MoveCut(int left, double seconds, bool growing)
+ {
+ if (growing)
+ {
+ var piece = _pieces[left];
+ var to = Math.Max(seconds, piece.Start + ClipRange.MinLength);
+ // Outwards: eat the cut-out pieces after it.
+ while (left + 1 < _pieces.Count && !_pieces[left + 1].IsKept && to >= _pieces[left + 1].End - ClipRange.MinLength)
+ {
+ _pieces.RemoveAt(left + 1);
+ }
+ var next = left + 1 < _pieces.Count ? _pieces[left + 1] : null;
+ if (next is null)
+ {
+ Bound(Start, Math.Clamp(to, piece.Start + ClipRange.MinLength, Duration));
+ return;
+ }
+ to = Math.Min(to, next.End - ClipRange.MinLength);
+ if (to < piece.End && piece.IsKept && !next.IsKept)
+ {
+ // Shortened next to a piece cut out: what it gives up joins that one.
+ _pieces[left] = piece with { End = to };
+ _pieces[left + 1] = next with { Start = to };
+ return;
+ }
+ if (to < piece.End && piece.IsKept && next.IsKept)
+ {
+ // Shortened next to a piece that plays: the given-up part is cut out, nothing else moves.
+ _pieces[left] = piece with { End = to };
+ _pieces.Insert(left + 1, new ClipPiece(to, next.Start, ClipPieceState.Removed, piece.Volume, piece.Speed, piece.Muted));
+ if (_pieces[left + 1].End - _pieces[left + 1].Start < 1e-9)
+ _pieces.RemoveAt(left + 1);
+ return;
+ }
+ _pieces[left] = piece with { End = to };
+ _pieces[left + 1] = next with { Start = to };
+ }
+ else
+ {
+ var right = left + 1;
+ var piece = _pieces[right];
+ var from = Math.Min(seconds, piece.End - ClipRange.MinLength);
+ // Outwards (to the left): eat the cut-out pieces before it.
+ while (right - 1 >= 0 && !_pieces[right - 1].IsKept && from <= _pieces[right - 1].Start + ClipRange.MinLength)
+ {
+ _pieces.RemoveAt(right - 1);
+ right--;
+ }
+ var previous = right - 1 >= 0 ? _pieces[right - 1] : null;
+ if (previous is null)
+ {
+ Bound(Math.Clamp(from, 0, piece.End - ClipRange.MinLength), End);
+ return;
+ }
+ from = Math.Max(from, previous.Start + ClipRange.MinLength);
+ if (from > piece.Start && piece.IsKept && previous.IsKept)
+ {
+ // Shortened next to a piece that plays: the given-up part is cut out.
+ _pieces[right] = piece with { Start = from };
+ _pieces.Insert(right, new ClipPiece(previous.End, from, ClipPieceState.Removed, piece.Volume, piece.Speed, piece.Muted));
+ return;
+ }
+ _pieces[right] = piece with { Start = from };
+ _pieces[right - 1] = previous with { End = from };
+ }
+ }
+
+ ///
+ /// The pieces between and : those outside dropped, the first and last
+ /// trimmed or stretched to the ends, slivers shorter than joined to their
+ /// neighbour. Something always plays.
+ ///
+ private void Bound(double start, double end)
+ {
+ var inside = _pieces
+ .Select(p => p with { Start = Math.Max(p.Start, start), End = Math.Min(p.End, end) })
+ .Where(p => p.End - p.Start > 1e-9)
+ .ToList();
+ if (inside.Count == 0)
+ inside = [_pieces[start >= End ? ^1 : 0] with { Start = start, End = end }];
+ inside[0] = inside[0] with { Start = start };
+ inside[^1] = inside[^1] with { End = end };
+ // Slivers left at the ends join the piece next to them.
+ while (inside.Count > 1 && inside[0].End - inside[0].Start < ClipRange.MinLength)
+ {
+ inside.RemoveAt(0);
+ inside[0] = inside[0] with { Start = start };
+ }
+ while (inside.Count > 1 && inside[^1].End - inside[^1].Start < ClipRange.MinLength)
+ {
+ inside.RemoveAt(inside.Count - 1);
+ inside[^1] = inside[^1] with { End = end };
+ }
+ if (!inside.Any(p => p.IsKept))
+ inside[0] = inside[0] with { State = ClipPieceState.Kept };
+ _pieces = inside;
+ }
+
+ /// cut at , unless it is (nearly) a cut already.
+ private static List Cut(List pieces, double seconds)
+ {
+ var index = pieces.FindIndex(p => seconds > p.Start + 1e-6 && seconds < p.End - 1e-6);
+ if (index < 0)
+ return pieces;
+ var piece = pieces[index];
+ var copy = new List(pieces);
+ copy[index] = piece with { End = seconds };
+ copy.Insert(index + 1, piece with { Start = seconds });
+ return copy;
+ }
+}
diff --git a/src/Filee.Core/Presets/ClipEdit.cs b/src/Filee.Core/Presets/ClipEdit.cs
new file mode 100644
index 000000000..88225dfb7
--- /dev/null
+++ b/src/Filee.Core/Presets/ClipEdit.cs
@@ -0,0 +1,272 @@
+// What "Cut a clip" writes: parts of one video one after the other, each with its own volume and speed (its sound
+// muted or not), pauses (black and silence) where the user removed a part without closing the gap, music from another
+// file under it, and a fade in and out. The clip window
+// edits it with ClipCutter; the conversion writes it with FFmpeg (MediaEncoding.ClipGraph).
+
+using System.Text.Json.Serialization;
+
+namespace Filee.Core.Presets;
+
+/// One part of a clip: a stretch of the video, or a pause of the same length.
+public sealed class ClipPart
+{
+ /// Slowest and fastest speed of a part.
+ public const double MinSpeed = 0.25, MaxSpeed = 4;
+
+ /// Loudest volume of a part: twice as loud (+6 dB), as far as the volume slider and line go. 0 is silent.
+ public const double MaxVolume = 2;
+
+ /// The speeds the clip window offers.
+ public static IReadOnlyList Speeds { get; } = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
+
+ /// Where the part starts in the video, in seconds.
+ public double Start { get; set; }
+
+ /// Where the part ends in the video, in seconds.
+ public double End { get; set; }
+
+ ///
+ /// A part removed with the magnet off: black and silence for as long as it played, so what follows stays where
+ /// it was.
+ ///
+ public bool Blank { get; set; }
+
+ /// How loud, 1 as recorded (0 silent, loudest).
+ public double Volume { get; set; } = 1;
+
+ /// The part's own sound is cut out (its picture plays): silence, or only the music.
+ public bool Muted { get; set; }
+
+ /// How fast, 1 as recorded ( to ); the sound keeps its pitch.
+ public double Speed { get; set; } = 1;
+
+ /// as written: within its limits, 1 for a value that makes no sense.
+ [JsonIgnore]
+ public double PlaySpeed => double.IsFinite(Speed) && Speed > 0 ? Math.Clamp(Speed, MinSpeed, MaxSpeed) : 1;
+
+ /// as written: within its limits, 1 for a value that makes no sense, 0 when muted.
+ [JsonIgnore]
+ public double PlayVolume => Muted ? 0 : double.IsFinite(Volume) && Volume >= 0 ? Math.Min(Volume, MaxVolume) : 1;
+
+ /// How long the part plays in the clip, in seconds (its stretch of the video at its speed).
+ [JsonIgnore]
+ public double Length => Math.Max(0, End - Start) / PlaySpeed;
+
+ /// True when the part plays as recorded: full volume, normal speed, not a pause.
+ [JsonIgnore]
+ public bool IsPlain => !Blank && !Muted && PlayVolume == 1 && PlaySpeed == 1;
+
+ public ClipPart Clone() => (ClipPart)MemberwiseClone();
+}
+
+/// Music (or any sound) from another file played under a clip, its own track.
+public sealed class ClipMusic
+{
+ /// How loud the music is to start with: under the clip's own sound, not over it.
+ public const double DefaultVolume = 0.6;
+
+ /// Seconds the music fades in and out at its ends, so it doesn't start or stop with a jump.
+ public const double FadeSeconds = 1;
+
+ /// The file the music comes from (any audio, or the sound of a video).
+ public string Path { get; set; } = "";
+
+ /// How far into the clip the music starts, in seconds.
+ public double Offset { get; set; }
+
+ /// Where in the file it starts playing, in seconds.
+ public double From { get; set; }
+
+ /// How long the file is, in seconds.
+ public double Length { get; set; }
+
+ /// How loud, 1 as recorded (0 to 2).
+ public double Volume { get; set; } = DefaultVolume;
+
+ /// Fade in and out at its ends ().
+ public bool Fade { get; set; } = true;
+
+ /// as written: 0 to 2, the default for a value that makes no sense.
+ [JsonIgnore]
+ public double PlayVolume => double.IsFinite(Volume) && Volume >= 0 ? Math.Min(Volume, 2) : DefaultVolume;
+
+ /// How long the music plays in a clip of seconds: until it or the clip ends.
+ public double PlayLength(double clipLength) => Math.Max(0, Math.Min(Length - From, clipLength - Offset));
+
+ public ClipMusic Clone() => (ClipMusic)MemberwiseClone();
+}
+
+/// A clip of a video: its parts in the order they play, music under them, and how it fades in and out.
+public sealed class ClipEdit
+{
+ /// The longest fade, in seconds.
+ public const double MaxFade = 10;
+
+ /// The parts, in the order they play (also their order in the video).
+ public List Parts { get; set; } = [];
+
+ /// Seconds the clip fades in from black and silence; 0 for none.
+ public double FadeIn { get; set; }
+
+ /// Seconds the clip fades out to black and silence; 0 for none.
+ public double FadeOut { get; set; }
+
+ /// Music under the clip, or null.
+ public ClipMusic? Music { get; set; }
+
+ /// How long the clip plays, in seconds.
+ [JsonIgnore]
+ public double Length => Parts.Sum(p => p.Length);
+
+ /// Where the clip starts in the video (its first part that isn't a pause).
+ [JsonIgnore]
+ public double Start => Parts.FirstOrDefault(p => !p.Blank)?.Start ?? 0;
+
+ /// Where the clip ends in the video (its last part that isn't a pause).
+ [JsonIgnore]
+ public double End => Parts.LastOrDefault(p => !p.Blank)?.End ?? 0;
+
+ ///
+ /// True for one stretch of the video as recorded, without fades: it can be copied as it is (cut at key frames).
+ /// Anything else is encoded.
+ ///
+ [JsonIgnore]
+ public bool IsPlainCut => Parts.Count == 1 && Parts[0].IsPlain && Fade(FadeIn) == 0 && Fade(FadeOut) == 0 && Music is null;
+
+ /// A fade as written: 0 to seconds.
+ public static double Fade(double seconds) => double.IsFinite(seconds) ? Math.Clamp(seconds, 0, MaxFade) : 0;
+
+ /// One stretch of the video, as recorded.
+ public static ClipEdit Of(ClipRange range) => new() { Parts = [new ClipPart { Start = range.Start, End = range.End }] };
+
+ public ClipEdit Clone() =>
+ new() { Parts = Parts.Select(p => p.Clone()).ToList(), FadeIn = FadeIn, FadeOut = FadeOut, Music = Music?.Clone() };
+
+ ///
+ /// The clip as it can be written from a video of seconds (null when unknown): every part
+ /// inside the video and at least long, in order and not overlapping (a part that
+ /// would be left empty is dropped), speeds and volumes within their limits, and fades no longer than half the clip.
+ ///
+ /// Nothing of the video is left to play.
+ public ClipEdit Clamped(double? duration)
+ {
+ var total = duration is > 0 and var d && double.IsFinite(d) ? d : (double?)null;
+ var parts = new List();
+ var last = 0.0;
+ foreach (var part in Parts)
+ {
+ if (!double.IsFinite(part.Start) || !double.IsFinite(part.End))
+ continue;
+ var start = Math.Max(part.Start, part.Blank ? part.Start : last);
+ var end = part.End;
+ if (total is { } length)
+ {
+ start = Math.Min(start, length);
+ end = Math.Min(end, length);
+ }
+ start = Math.Max(0, start);
+ if (end - start < ClipRange.MinLength)
+ continue;
+ parts.Add(new ClipPart
+ {
+ Start = start,
+ End = end,
+ Blank = part.Blank,
+ Volume = part.Muted ? part.Volume : part.PlayVolume,
+ Muted = part.Muted,
+ Speed = part.PlaySpeed,
+ });
+ if (!part.Blank)
+ last = end;
+ }
+ if (!parts.Any(p => !p.Blank))
+ throw new InvalidOperationException("The clip has no part of the video left.");
+ // Pauses only between parts that play: at either end they would only be black.
+ while (parts[0].Blank)
+ parts.RemoveAt(0);
+ while (parts[^1].Blank)
+ parts.RemoveAt(parts.Count - 1);
+ var clip = new ClipEdit { Parts = parts };
+ var half = clip.Length / 2;
+ clip.FadeIn = Math.Min(Fade(FadeIn), half);
+ clip.FadeOut = Math.Min(Fade(FadeOut), half);
+ // Music only where it plays: from 0 into the clip, from a place in its file, for some time.
+ if (Music is { Path.Length: > 0 } music && double.IsFinite(music.Offset) && double.IsFinite(music.From) && double.IsFinite(music.Length))
+ {
+ var kept = music.Clone();
+ kept.Offset = Math.Max(0, kept.Offset);
+ kept.From = Math.Clamp(kept.From, 0, Math.Max(0, kept.Length));
+ kept.Volume = kept.PlayVolume;
+ if (kept.PlayLength(clip.Length) >= ClipRange.MinLength)
+ clip.Music = kept;
+ }
+ return clip;
+ }
+
+ ///
+ /// The rest of the clip from into it on, for playing it from there: the part playing
+ /// then starts where it is, the parts before it are left out, the fade in only where it is still running.
+ ///
+ public ClipEdit From(double seconds)
+ {
+ var rest = new ClipEdit { FadeOut = FadeOut };
+ if (Music?.Clone() is { } music)
+ {
+ // The music keeps its place against the pictures: what has played of it is left out.
+ var played = Math.Max(0, seconds - music.Offset);
+ music.From += played;
+ music.Offset = Math.Max(0, music.Offset - Math.Max(0, seconds));
+ rest.Music = music;
+ }
+ var at = 0.0;
+ var skip = Math.Max(0, seconds);
+ foreach (var part in Parts)
+ {
+ var length = part.Length;
+ if (at + length <= skip)
+ {
+ at += length;
+ continue;
+ }
+ var copy = part.Clone();
+ if (at < skip)
+ copy.Start += (skip - at) * part.PlaySpeed;
+ rest.Parts.Add(copy);
+ at += length;
+ }
+ rest.FadeIn = Math.Max(0, Fade(FadeIn) - skip);
+ return rest;
+ }
+
+ ///
+ /// Where in the video the clip is at into it, for the playhead on the timeline; in a
+ /// pause, where the pause stands.
+ ///
+ public double SourceTimeAt(double seconds)
+ {
+ var at = 0.0;
+ foreach (var part in Parts)
+ {
+ var length = part.Length;
+ if (seconds <= at + length)
+ return part.Start + Math.Max(0, seconds - at) * part.PlaySpeed;
+ at += length;
+ }
+ return Parts.Count > 0 ? Parts[^1].End : 0;
+ }
+
+ /// How far into the clip the video at plays (the next part's start when it is cut out).
+ public double ClipTimeAt(double sourceTime)
+ {
+ var at = 0.0;
+ foreach (var part in Parts)
+ {
+ if (sourceTime < part.Start)
+ return at;
+ if (sourceTime <= part.End)
+ return at + (sourceTime - part.Start) / part.PlaySpeed;
+ at += part.Length;
+ }
+ return at;
+ }
+}
diff --git a/src/Filee.Core/Presets/ClipRange.cs b/src/Filee.Core/Presets/ClipRange.cs
new file mode 100644
index 000000000..631e68664
--- /dev/null
+++ b/src/Filee.Core/Presets/ClipRange.cs
@@ -0,0 +1,69 @@
+// A part of a video for "Cut a clip": where it starts and ends, kept inside the video, and the times as the clip
+// window shows them (1:05.3) and as FFmpeg reads them (65.300).
+
+using System.Globalization;
+
+namespace Filee.Core.Presets;
+
+/// A clip of a video, in seconds from its start: before .
+public readonly record struct ClipRange(double Start, double End)
+{
+ /// The shortest clip, in seconds: about three frames, so a clip is never empty.
+ public const double MinLength = 0.1;
+
+ /// How long the clip is, in seconds.
+ public double Length => End - Start;
+
+ ///
+ /// The clip from to inside a video of
+ /// seconds (null or 0 when unknown: only the start is kept at 0 or later). The ends are swapped when given the wrong
+ /// way round, and a clip shorter than grows to it, towards the end while there is room.
+ /// Values that are no numbers count as 0 (start) and the whole video (end).
+ ///
+ public static ClipRange Clamp(double start, double end, double? duration)
+ {
+ var total = duration is > 0 and var d && double.IsFinite(d) ? d : (double?)null;
+ start = double.IsFinite(start) ? start : 0;
+ end = double.IsFinite(end) ? end : total ?? start + MinLength;
+ if (end < start)
+ (start, end) = (end, start);
+ start = Math.Max(0, start);
+ end = Math.Max(0, end);
+ if (total is { } length)
+ {
+ start = Math.Min(start, length);
+ end = Math.Min(end, length);
+ }
+ if (end - start < MinLength)
+ {
+ end = start + MinLength;
+ if (total is { } length2 && end > length2)
+ {
+ end = length2;
+ start = Math.Max(0, end - MinLength);
+ }
+ }
+ return new ClipRange(start, end);
+ }
+
+ ///
+ /// A time as the clip window shows it, to a tenth of a second: 0:07.5, 4:05.0, 1:02:05.3
+ /// (hours only when there are any). Tenths are rounded, so 59.96 s is 1:00.0; a negative or unknown time is 0.
+ ///
+ public static string Display(double seconds)
+ {
+ var tenths = double.IsFinite(seconds) && seconds > 0 ? (long)Math.Round(seconds * 10, MidpointRounding.AwayFromZero) : 0;
+ var hours = tenths / 36_000;
+ var minutes = tenths / 600 % 60;
+ var secs = tenths / 10 % 60;
+ var tenth = tenths % 10;
+ var invariant = CultureInfo.InvariantCulture;
+ return hours > 0
+ ? string.Create(invariant, $"{hours}:{minutes:00}:{secs:00}.{tenth}")
+ : string.Create(invariant, $"{minutes}:{secs:00}.{tenth}");
+ }
+
+ /// A time as FFmpeg's -ss and -to read it: seconds with three decimals, 65.300.
+ public static string Ffmpeg(double seconds) =>
+ Math.Max(0, double.IsFinite(seconds) ? seconds : 0).ToString("0.000", CultureInfo.InvariantCulture);
+}
diff --git a/src/Filee.Core/Presets/ClipTool.cs b/src/Filee.Core/Presets/ClipTool.cs
new file mode 100644
index 000000000..e4d063cfd
--- /dev/null
+++ b/src/Filee.Core/Presets/ClipTool.cs
@@ -0,0 +1,51 @@
+// "Cut a clip" runs as an ordinary conversion once the clip window has its part of the video: the tool preset becomes
+// a conversion of that part into the video's own format (or MP4), so the job queue, the toast, cancelling, the output
+// rules and the history work as for any other file.
+
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+
+namespace Filee.Core.Presets;
+
+/// Turns the "Cut a clip" tool into the conversion that writes one clip.
+public static class ClipTool
+{
+ /// Where a clip goes when the video's own format can't be written: plays everywhere.
+ public const string FallbackTarget = "mp4";
+
+ ///
+ /// The format a clip of a video is written in: its own when one engine writes it
+ /// straight from the video (MP4 → MP4, MKV → MKV), else (RealVideo, WTV, …).
+ ///
+ public static string TargetFor(string sourceFormat, RoutePlanner planner) =>
+ planner.Plan(sourceFormat, sourceFormat) is { Steps.Count: 1 } ? sourceFormat : FallbackTarget;
+
+ /// True when a clip can be cut of a file: it is a video that can be written.
+ public static bool CanCut(string sourceFormat, RoutePlanner planner) =>
+ FormatRegistry.FindById(sourceFormat)?.Category == FormatCategory.Video
+ && planner.Plan(sourceFormat, TargetFor(sourceFormat, planner)) is { Steps.Count: 1 };
+
+ /// The conversion that writes one stretch of a video, as recorded (see the other overload).
+ public static Preset ConversionOf(Preset tool, string targetFormat, ClipRange clip) =>
+ ConversionOf(tool, targetFormat, ClipEdit.Of(clip));
+
+ ///
+ /// The conversion that writes of a video as with the
+ /// tool's name, output rule and media options (which an encoded clip is written with). The tool itself is left as
+ /// it is.
+ ///
+ public static Preset ConversionOf(Preset tool, string targetFormat, ClipEdit clip)
+ {
+ var preset = tool.Clone();
+ preset.Kind = PresetKind.Convert;
+ preset.Tool = null;
+ preset.SubPresetIds = [];
+ preset.TargetFormat = targetFormat;
+ // Only the clip: none of the other things a video preset can make of a video.
+ preset.Media.SaveEveryFrame = false;
+ preset.Media.ExtractMotionPhoto = false;
+ preset.Media.LivePhoto = LivePhotoKind.None;
+ preset.Media.Clip = clip.Clone();
+ return preset;
+ }
+}
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 99770c146..6ea062ff8 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -18,8 +18,8 @@ public enum PresetKind
Group,
///
- /// Opens its own window on the dropped files instead of converting them (the interactive Crop). Like a group,
- /// never offered where a conversion is picked.
+ /// Opens its own window on the dropped files instead of converting them (the interactive Crop, Cut a clip; see
+ /// ). Like a group, never offered where a conversion is picked.
///
Tool,
}
@@ -188,6 +188,34 @@ public bool RecognizesTextOf(string sourceFormat) =>
[JsonIgnore]
public bool IsTool => Kind == PresetKind.Tool;
+ /// of the interactive crop (also a tool saved without one, from before clips).
+ public const string CropTool = "crop";
+
+ /// of "Cut a clip": a part of a video, chosen on a timeline.
+ public const string ClipTool = "clip";
+
+ ///
+ /// : which window it opens, (or null, as saved before there were
+ /// other tools) or . A tool this version doesn't know (from a newer Filee) opens nothing.
+ /// Ignored for other kinds.
+ ///
+ public string? Tool { get; set; }
+
+ /// True for the interactive crop.
+ [JsonIgnore]
+ public bool IsCropTool => IsTool && Tool is null or CropTool;
+
+ /// True for "Cut a clip".
+ [JsonIgnore]
+ public bool IsClipTool => IsTool && Tool == ClipTool;
+
+ ///
+ /// True if converting a file cuts a clip of it (,
+ /// set by the clip window on the conversion it starts): the file is a video. Other files convert as usual.
+ ///
+ public bool CutsClipOf(string sourceFormat) =>
+ Media.Clip is { Parts.Count: > 0 } && Formats.FormatRegistry.FindById(sourceFormat)?.Category == Formats.FormatCategory.Video;
+
/// Creates a new random preset id.
public static string NewId() => Guid.NewGuid().ToString("N")[..12];
@@ -221,6 +249,7 @@ public Preset WithoutPassword(bool mark)
Cad = Cad.Clone(),
Output = Output.Clone(),
Kind = Kind,
+ Tool = Tool,
SubPresetIds = [.. SubPresetIds],
};
}
@@ -655,6 +684,19 @@ public sealed class MediaOptions
/// With : extract every Nth frame (1 = every frame).
public int FrameInterval { get; set; } = 1;
+ ///
+ /// "Cut a clip": cut exactly at the chosen frames by encoding the clip again (with ,
+ /// , , …). Off by default: the streams are copied as they are, which
+ /// takes a moment and loses nothing, but the clip starts at the key frame before the chosen start.
+ ///
+ public bool ExactCut { get; set; }
+
+ ///
+ /// A clip of a video (): its parts, their volume and speed, and its fades. Set by the
+ /// clip window on the conversion it starts, never on a saved preset.
+ ///
+ public ClipEdit? Clip { get; set; }
+
///
/// Most pictures one video may become (): about an hour at 30 fps, already more than
/// anyone looks through. A longer video needs a larger .
@@ -713,7 +755,12 @@ public sealed class MediaOptions
[JsonIgnore]
public double FadeDuration => Crossfade ? Math.Min(CrossfadeSeconds, SlideDuration / 2) : 0;
- public MediaOptions Clone() => (MediaOptions)MemberwiseClone();
+ public MediaOptions Clone()
+ {
+ var copy = (MediaOptions)MemberwiseClone();
+ copy.Clip = Clip?.Clone();
+ return copy;
+ }
}
/// The kind of moving photo a video becomes ().
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index f9baf498e..b503eda36 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -123,7 +123,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 = 44;
+ public const int CurrentSchemaVersion = 45;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 5a996e061..c26071673 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -843,6 +843,13 @@ public static bool ApplyToLibrary(int from, List presets, List presets, IEnumerable presets, List profiles)
+ {
+ var changed = false;
+ var clip = BuiltInData.ClipPresets().Single();
+ if (presets.FirstOrDefault(p => p.Id == clip.Id) is { } existing)
+ {
+ if (!existing.IsClipTool)
+ {
+ existing.Kind = PresetKind.Tool;
+ existing.Tool = Preset.ClipTool;
+ existing.TargetFormat = clip.TargetFormat;
+ existing.SubPresetIds = [];
+ changed = true;
+ }
+ }
+ else
+ {
+ var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion");
+ presets.Insert(at < 0 ? presets.Count : at + 1, clip);
+ changed = true;
+ }
+ if (profiles.FirstOrDefault(p => p.Id == "video") is { BackPresetIds: { } back } video
+ && !back.Contains(clip.Id) && !video.PresetIds.Contains(clip.Id))
+ {
+ changed |= back.Remove("to-flac");
+ if (back.Count < ToolbarProfile.MaxSlices)
+ {
+ back.Insert(0, clip.Id);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
private static bool AddGeo(List presets, List profiles)
{
var changed = false;
diff --git a/src/Filee.Engines/Media/ClipPreview.cs b/src/Filee.Engines/Media/ClipPreview.cs
new file mode 100644
index 000000000..9d9f5d617
--- /dev/null
+++ b/src/Filee.Engines/Media/ClipPreview.cs
@@ -0,0 +1,338 @@
+// The clip window's preview: plays a clip as it will be written (its parts, speeds, volumes, pauses and fades), the
+// pictures as raw frames from FFmpeg, the sound through ffplay (FFmpeg's player, part of Filee's FFmpeg download;
+// without it the preview plays silent). Also the sound's loudness over the video for the timeline, and where it is
+// silent, for "Remove silence".
+
+using System.Diagnostics;
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Filee.Core.Presets;
+using Filee.Engines.Infrastructure;
+
+namespace Filee.Engines.Media;
+
+/// Plays clips and reads a video's sound for the clip window.
+public static partial class ClipPreview
+{
+ /// Frames per second of the preview.
+ public const int Fps = 25;
+
+ /// Samples per second the loudness is read at: enough for a timeline, little to read.
+ private const int PeakRate = 2000;
+
+ /// Quieter than this is silence, for "Remove silence".
+ public const double SilenceThreshold = -35;
+
+ ///
+ /// How long the sound takes to start once ffplay has it (opening the sound device): the pictures wait as long, so
+ /// both start together.
+ ///
+ private static readonly TimeSpan SoundStart = TimeSpan.FromMilliseconds(180);
+
+ /// The player's volume (0–100) instead of full; tests play silently with 0.
+ internal static int? PlayerVolume { get; set; }
+
+ /// Told how many bytes of sound reached the player once a clip has played (tests).
+ internal static Action? SoundPlayed { get; set; }
+
+ /// True when the preview plays sound: ffplay is next to Filee's FFmpeg.
+ public static bool HasSound => Player() is not null;
+
+ private static string? Player() =>
+ FfmpegConverter.Locate() is { } tools
+ && Path.Combine(Path.GetDirectoryName(tools.Ffmpeg) ?? "", EngineEnvironment.ProgramName("ffplay.exe")) is var ffplay
+ && File.Exists(ffplay) ? ffplay : null;
+
+ ///
+ /// Plays of the video at from its start (use
+ /// to start later): each picture, pixels high in BGRA, goes to
+ /// (pixels, width, height, seconds into ) when it is due; the sound
+ /// plays along. Ends when the clip has played or is cancelled (then it stops at
+ /// once and doesn't throw). is called on a worker thread, with a buffer it may keep only
+ /// until it returns.
+ ///
+ /// FFmpeg is missing or the video can't be read.
+ public static async Task PlayAsync(string path, ClipEdit edit, int height, Action onFrame,
+ CancellationToken cancellationToken)
+ {
+ var (ffmpeg, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var input = Path.GetFullPath(path);
+ var info = await MediaInfo.ProbeAsync(ffprobe, input, cancellationToken).ConfigureAwait(false);
+ var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ ClipEdit clip;
+ try
+ {
+ clip = edit.Clamped(info.Duration?.TotalSeconds);
+ }
+ catch (InvalidOperationException)
+ {
+ return; // nothing left to play
+ }
+
+ using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var pictures = Start(ffmpeg, MediaEncoding.ClipPreviewVideo(input, clip, video, height, Fps, out var width, out var frameHeight), stdin: false);
+ Process? decoder = null, player = null;
+ Task pump = Task.CompletedTask;
+ try
+ {
+ if ((info.Audio is not null || clip.Music is not null) && Player() is { } ffplay)
+ {
+ decoder = Start(ffmpeg, MediaEncoding.ClipPreviewAudio(input, clip, video, info.Audio is not null), stdin: false);
+ player = Start(ffplay, ["-hide_banner", "-loglevel", "error", "-nodisp", "-autoexit",
+ .. PlayerVolume is { } volume ? new[] { "-volume", volume.ToString(CultureInfo.InvariantCulture) } : [], "-i", "-"], stdin: true);
+ pump = Pump(decoder, player, stop.Token);
+ }
+
+ var frame = new byte[width * frameHeight * 4];
+ // The clock starts with the first picture: FFmpeg takes a moment to seek and start, and pictures counted
+ // from before that would all be late (and skipped) until it caught up.
+ Stopwatch? clock = null;
+ var delay = player is null ? TimeSpan.Zero : SoundStart;
+ for (var n = 0; !stop.IsCancellationRequested; n++)
+ {
+ if (!await ReadFully(pictures.StandardOutput.BaseStream, frame, stop.Token).ConfigureAwait(false))
+ break;
+ clock ??= Stopwatch.StartNew();
+ var due = delay + TimeSpan.FromSeconds((double)n / Fps);
+ var wait = due - clock.Elapsed;
+ if (wait > TimeSpan.Zero)
+ await Task.Delay(wait, stop.Token).ConfigureAwait(false);
+ else if (wait < TimeSpan.FromSeconds(-0.2))
+ continue; // behind: skip a picture rather than run late
+ onFrame(frame, width, frameHeight, (double)n / Fps);
+ }
+ // The sound may still be playing its last moment.
+ if (!stop.IsCancellationRequested && player is not null)
+ await Task.WhenAny(player.WaitForExitAsync(stop.Token), Task.Delay(TimeSpan.FromSeconds(1), stop.Token)).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Stopped.
+ }
+ finally
+ {
+ await stop.CancelAsync().ConfigureAwait(false);
+ foreach (var process in new[] { pictures, decoder, player })
+ {
+ if (process is null)
+ continue;
+ Kill(process);
+ process.Dispose();
+ }
+ try
+ {
+ await pump.ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException or InvalidOperationException)
+ {
+ // The player went away first.
+ }
+ }
+ }
+
+ ///
+ /// How loud the sound of the video at is over its seconds, in
+ /// steps from 0 (silent) to 1 (its loudest step); empty when it has no sound.
+ ///
+ /// Where the samples are written for a moment.
+ public static Task ReadLoudnessAsync(string path, double duration, int count, string workFolder, CancellationToken cancellationToken) =>
+ ReadLoudnessAsync(path, 0, duration, count, workFolder, cancellationToken);
+
+ ///
+ /// How loud the sound of the file at is from to
+ /// seconds, in steps from 0 to 1 (its loudest step); empty
+ /// without sound.
+ ///
+ public static async Task ReadLoudnessAsync(string path, double start, double end, int count, string workFolder,
+ CancellationToken cancellationToken)
+ {
+ var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var samples = Path.Combine(workFolder, Guid.NewGuid().ToString("N") + ".f32");
+ try
+ {
+ List args = [.. MediaEncoding.Common];
+ if (start > 0)
+ args.AddRange(["-ss", ClipRange.Ffmpeg(start)]);
+ if (end > start)
+ args.AddRange(["-to", ClipRange.Ffmpeg(end)]);
+ args.AddRange(["-i", Path.GetFullPath(path), "-map", "0:a:0", "-ac", "1", "-ar", PeakRate.ToString(CultureInfo.InvariantCulture),
+ "-c:a", "pcm_f32le", "-f", "f32le", samples]);
+ var result = await FfmpegRunner.RunAsync(ffmpeg, args, null, cancellationToken, background: true);
+ if (result.ExitCode != 0 || !File.Exists(samples))
+ return []; // no sound
+ var bytes = await File.ReadAllBytesAsync(samples, cancellationToken);
+ return Loudness(MemoryMarshalFloats(bytes), PeakRate, end > start ? end - start : 0, count);
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(samples);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+ }
+
+ /// How long the sound (or video) at plays, in seconds; throws when it has no sound.
+ public static async Task ReadSoundLengthAsync(string path, CancellationToken cancellationToken)
+ {
+ var (_, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var info = await MediaInfo.ProbeAsync(ffprobe, Path.GetFullPath(path), cancellationToken);
+ if (info.Audio is null)
+ throw new InvalidOperationException("The file has no sound.");
+ return info.Duration?.TotalSeconds is > 0 and var seconds ? seconds : throw new InvalidOperationException("The file doesn't say how long it is.");
+ }
+
+ /// The silent stretches of the video at between and .
+ public static async Task> FindSilencesAsync(string path, double start, double end, CancellationToken cancellationToken)
+ {
+ var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var result = await ProcessRunner.RunAsync(ffmpeg, SilenceArguments(Path.GetFullPath(path), start, end), TimeSpan.FromMinutes(10), cancellationToken);
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException("FFmpeg failed: " + FfmpegRunner.Summarize(result.StandardError));
+ return ParseSilences(result.StandardError, start, end);
+ }
+
+ /// FFmpeg's silencedetect over to of the video, reported on stderr.
+ internal static List SilenceArguments(string input, double start, double end) =>
+ [
+ "-hide_banner", "-nostdin", "-nostats", "-v", "info", "-ss", ClipRange.Ffmpeg(start), "-to", ClipRange.Ffmpeg(end), "-i", input,
+ "-map", "0:a:0", "-af", $"silencedetect=noise={SilenceThreshold.ToString(CultureInfo.InvariantCulture)}dB:d={ClipCutter.MinSilence.ToString(CultureInfo.InvariantCulture)}",
+ "-f", "null", "-",
+ ];
+
+ ///
+ /// The silences silencedetect reported (silence_start: 1.5 … silence_end: 3.25), in seconds of the
+ /// video: its times count from , where it started reading; one still silent at the end
+ /// lasts until .
+ ///
+ internal static IReadOnlyList ParseSilences(string log, double offset, double end)
+ {
+ var silences = new List();
+ double? open = null;
+ foreach (Match match in SilenceLine().Matches(log))
+ {
+ if (!double.TryParse(match.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds))
+ continue;
+ var at = Math.Max(offset, offset + seconds);
+ if (match.Groups[1].Value == "start")
+ open = at;
+ else if (open is { } from)
+ {
+ if (Math.Min(at, end) > from)
+ silences.Add(new ClipRange(from, Math.Min(at, end)));
+ open = null;
+ }
+ }
+ if (open is { } last && end > last)
+ silences.Add(new ClipRange(last, end));
+ return silences;
+ }
+
+ [GeneratedRegex(@"silence_(start|end):\s*(-?[0-9.]+(?:e-?[0-9]+)?)")]
+ private static partial Regex SilenceLine();
+
+ ///
+ /// The loudness of (mono, a second) in
+ /// equal steps of seconds: the root mean square of each step, the loudest step 1.
+ ///
+ internal static float[] Loudness(ReadOnlySpan samples, int rate, double duration, int count)
+ {
+ if (count <= 0 || samples.Length == 0)
+ return [];
+ var steps = new float[count];
+ var total = duration > 0 ? duration * rate : samples.Length;
+ for (var i = 0; i < count; i++)
+ {
+ var from = (int)Math.Min(samples.Length, Math.Floor(total * i / count));
+ var to = (int)Math.Min(samples.Length, Math.Floor(total * (i + 1) / count));
+ if (to <= from)
+ continue;
+ double sum = 0;
+ for (var s = from; s < to; s++)
+ sum += samples[s] * samples[s];
+ steps[i] = (float)Math.Sqrt(sum / (to - from));
+ }
+ var loudest = steps.Max();
+ if (loudest > 0)
+ {
+ for (var i = 0; i < count; i++)
+ steps[i] /= loudest;
+ }
+ return steps;
+ }
+
+ private static ReadOnlySpan MemoryMarshalFloats(byte[] bytes) =>
+ System.Runtime.InteropServices.MemoryMarshal.Cast(bytes.AsSpan(0, bytes.Length / 4 * 4));
+
+ private static Process Start(string executable, IReadOnlyList arguments, bool stdin)
+ {
+ var psi = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = !stdin,
+ RedirectStandardInput = stdin,
+ RedirectStandardError = true,
+ WorkingDirectory = Path.GetDirectoryName(executable) ?? "",
+ };
+ foreach (var argument in arguments)
+ psi.ArgumentList.Add(argument);
+ var process = new Process { StartInfo = psi };
+ if (!process.Start())
+ throw new InvalidOperationException($"Could not start {executable}.");
+ // Nobody reads the messages: drained, so a chatty process never blocks on a full pipe.
+ process.ErrorDataReceived += (_, _) => { };
+ process.BeginErrorReadLine();
+ return process;
+ }
+
+ /// Passes the decoded sound on to the player.
+ private static async Task Pump(Process decoder, Process player, CancellationToken cancellationToken)
+ {
+ long passed = 0;
+ try
+ {
+ var buffer = new byte[64 * 1024];
+ int read;
+ while ((read = await decoder.StandardOutput.BaseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ await player.StandardInput.BaseStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ passed += read;
+ }
+ }
+ finally
+ {
+ SoundPlayed?.Invoke(passed);
+ try
+ {
+ player.StandardInput.Close();
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException) { }
+ }
+ }
+
+ private static async Task ReadFully(Stream stream, byte[] buffer, CancellationToken cancellationToken)
+ {
+ var read = 0;
+ while (read < buffer.Length)
+ {
+ var n = await stream.ReadAsync(buffer.AsMemory(read), cancellationToken).ConfigureAwait(false);
+ if (n == 0)
+ return false;
+ read += n;
+ }
+ return true;
+ }
+
+ private static void Kill(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException) { }
+ catch (System.ComponentModel.Win32Exception) { }
+ }
+}
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index 27d808a3d..ddd5a6f0d 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -6,7 +6,8 @@
// in HardwareVideo (options) and HardwareProbe (which one works here).
// Live / Motion Photos (Android, iPhone) give their video when a preset asks for it (LivePhotoSource, MotionPhoto), and
// a clip of a video becomes one (LivePhotoWriter). Clips and pictures are joined into one file by IMediaJoiner
-// (MediaJoining), and "Remove metadata" copies a file's streams without its tags.
+// (MediaJoining), and "Remove metadata" copies a file's streams without its tags. "Cut a clip" copies or encodes a part
+// of a video (CutClipAsync).
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
@@ -133,6 +134,8 @@ public async Task> ConvertAsync(ConversionStep step, IProg
if (step.FrameSequence)
return await FrameSequenceWriter.ConvertAsync(ffmpeg, step, input, info, progress, cancellationToken);
+ if (step.Preset.CutsClipOf(step.From) && MediaEncoding.VideoTargets.ContainsKey(step.To))
+ return await CutClipAsync(ffmpeg, step, input, info, progress, cancellationToken);
var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
if (output is null)
@@ -297,6 +300,82 @@ private static async Task RunJoinPassAsync(string ffmpeg, IReadOnlyList
}
}
+ ///
+ /// "Cut a clip": the clip the window made (, kept inside the video). One stretch as
+ /// recorded is copied as it is unless the preset asks for an exact cut (); a copy
+ /// the target can't take (a codec its container doesn't hold) is encoded exactly instead, like the preset's other
+ /// videos. Several parts, speeds, volumes, pauses or fades are encoded through one filter graph.
+ ///
+ private async Task> CutClipAsync(string ffmpeg, ConversionStep step, string input, MediaInfo info,
+ IProgress? progress, CancellationToken cancellationToken)
+ {
+ if (info.Video is null)
+ throw new InvalidOperationException("The file has no video stream.");
+ var media = step.Preset.Media;
+ var edit = media.Clip!.Clamped(info.Duration?.TotalSeconds);
+ var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
+ if (output is null)
+ return [];
+ var duration = TimeSpan.FromSeconds(edit.Length);
+ if (!edit.IsPlainCut)
+ {
+ // Parts, speeds, volumes, pauses or fades: one filter graph, always encoded.
+ try
+ {
+ var pass = new FfmpegPass(MediaEncoding.ClipEditArguments(input, output, step.To, edit, media, info, EncodersOf(ffmpeg)), 1);
+ await RunPassesAsync(ffmpeg, [pass], output, duration, progress, cancellationToken);
+ }
+ catch
+ {
+ TryDelete(output);
+ throw;
+ }
+ progress?.Report(1);
+ return [output];
+ }
+
+ var clip = new ClipRange(edit.Parts[0].Start, edit.Parts[0].End);
+ try
+ {
+ if (!media.ExactCut)
+ {
+ try
+ {
+ var copy = new FfmpegPass(MediaEncoding.ClipCopy(input, output, step.To, clip, media, info), 1);
+ await RunPassesAsync(ffmpeg, [copy], output, duration, progress, cancellationToken);
+ progress?.Report(1);
+ return [output];
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested)
+ {
+ _logger.LogWarning("{File}: copying the clip failed ({Reason}); encoding it instead", input, ex.Message);
+ TryDelete(output);
+ }
+ }
+
+ var encoders = EncodersOf(ffmpeg);
+ HardwareEncoder? hardware = null;
+ if (MediaEncoding.HardwareCodec(step.To, media, info, encode: true) is { } codec)
+ {
+ var tenBit = HardwareVideo.TenBit(codec, info.Video);
+ hardware = SelectHardware is { } select
+ ? await select(ffmpeg, codec, tenBit, encoders, cancellationToken)
+ : await HardwareProbe.SelectAsync(ffmpeg, codec, tenBit, encoders, _logger, cancellationToken);
+ }
+ IReadOnlyList Plan(HardwareEncoder? encoder) =>
+ MediaEncoding.Plan(input, output, step.To, media, info, step.WorkDirectory, encoders: encoders, hardware: encoder, clip: clip);
+ await RunWithFallbackAsync(Plan(hardware), hardware, Plan,
+ planned => RunPassesAsync(ffmpeg, planned, output, duration, progress, cancellationToken), output, _logger, cancellationToken);
+ }
+ catch
+ {
+ TryDelete(output);
+ throw;
+ }
+ progress?.Report(1);
+ return [output];
+ }
+
///
/// The video of a Live / Motion Photo as : copied out of the photo (or from next to
/// it) into the work directory, converted there like any video and moved into place once finished, so a failure
diff --git a/src/Filee.Engines/Media/FfmpegRunner.cs b/src/Filee.Engines/Media/FfmpegRunner.cs
index b049b9bc7..eba84e03f 100644
--- a/src/Filee.Engines/Media/FfmpegRunner.cs
+++ b/src/Filee.Engines/Media/FfmpegRunner.cs
@@ -30,9 +30,13 @@ internal static class FfmpegRunner
/// Called with the output position in seconds whenever FFmpeg reports one.
/// How long FFmpeg may stay silent before it is killed; when null.
/// Exit code and the last lines FFmpeg wrote to stderr (its error messages).
+ ///
+ /// Runs below normal priority: pictures and sound for a window (the clip window's timeline), which must not take
+ /// the processor from the window itself.
+ ///
public static async Task RunAsync(
string ffmpeg, IReadOnlyList arguments, Action? onPosition, CancellationToken cancellationToken,
- TimeSpan? stallTimeout = null)
+ TimeSpan? stallTimeout = null, bool background = false)
{
var psi = new ProcessStartInfo(ffmpeg)
{
@@ -75,6 +79,8 @@ public static async Task RunAsync(
if (!process.Start())
throw new InvalidOperationException($"Could not start {ffmpeg}.");
+ if (background)
+ Lower(process);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
@@ -109,6 +115,18 @@ public static async Task RunAsync(
return new ProcessResult(process.ExitCode, "", stderr);
}
+ /// Below normal priority; a process that is already gone or not ours to change stays as it is.
+ internal static void Lower(Process process)
+ {
+ try
+ {
+ process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException)
+ {
+ }
+ }
+
///
/// Runs a short query (ffmpeg -version, -encoders) synchronously. Both streams are read in the
/// background, so the time limit holds even when the program hangs with its output open (reading it to the end
diff --git a/src/Filee.Engines/Media/MediaEncoding.Clip.cs b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
new file mode 100644
index 000000000..aa4e96f7f
--- /dev/null
+++ b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
@@ -0,0 +1,252 @@
+// "Cut a clip" with parts: one FFmpeg filter graph plays the parts of the video one after the other, each at its
+// speed and volume, with black and silence for the pauses and the fades at the ends. The same graph writes the clip
+// (encoded with the preset's options) and plays it in the clip window (small pictures and the sound).
+
+using System.Globalization;
+using System.Text;
+using Filee.Core.Presets;
+
+namespace Filee.Engines.Media;
+
+/// The inputs and the filter graph of a clip: its picture comes out of [v], its sound of [a].
+///
+/// The input options: the video from the first part's start to the last one's end, and the music's file when there is one.
+///
+/// The -filter_complex graph.
+/// True when the graph has [a] (the video's sound, the music, or both).
+/// True when the graph has [v] (false for the preview's sound alone).
+/// How long the clip plays, in seconds.
+internal sealed record ClipGraph(IReadOnlyList Inputs, string Filter, bool HasAudio, double Length, bool HasVideo = true);
+
+internal static partial class MediaEncoding
+{
+ /// Sample rate and layout every part's sound is brought to, so the parts join.
+ private const string ClipAudioFormat = "aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo";
+
+ ///
+ /// The graph that plays of : its picture ×
+ /// at frames per second, its sound 48 kHz stereo: the video's own
+ /// (when ; muted parts silent) with the clip's music mixed under it. The video is read once,
+ /// from the first part to the last: -ss / -to before -i, the parts cut out of it with
+ /// trim, so a clip late in a long video starts at once. The music is the second input, read from where it
+ /// starts playing.
+ ///
+ /// The clip, already to the video.
+ /// The pictures' format, the same for every part (pauses included) so they join.
+ ///
+ /// False for the sound alone (the preview's): FFmpeg refuses a graph with a picture output nothing takes.
+ ///
+ /// The video's own sound is used (it has sound, and the preset keeps it).
+ /// The clip's music is mixed in (the preset keeps sound).
+ internal static ClipGraph ClipGraphOf(string input, ClipEdit edit, bool audio, int width, int height, string fps, string pixelFormat = "yuv420p",
+ bool video = true, bool music = true)
+ {
+ var song = music ? edit.Music : null;
+ var sound = audio || song is not null;
+ if (!video && !sound)
+ throw new ArgumentException("A clip's graph needs pictures or sound.", nameof(audio));
+ var sourceParts = edit.Parts.Where(p => !p.Blank).ToList();
+ if (sourceParts.Count == 0)
+ throw new InvalidOperationException("The clip has no part of the video.");
+ var from = sourceParts[0].Start;
+ var to = sourceParts[^1].End;
+ List inputs = ["-ss", ClipRange.Ffmpeg(from), "-to", ClipRange.Ffmpeg(to), "-i", input];
+ if (song is not null)
+ inputs.AddRange(["-ss", ClipRange.Ffmpeg(song.From), "-i", song.Path]);
+
+ var graph = new StringBuilder();
+ var n = sourceParts.Count;
+ if (video)
+ {
+ graph.Append("[0:V:0]").Append(n > 1 ? $"split={n}" : "null");
+ for (var k = 0; k < n; k++)
+ graph.Append($"[vs{k}]");
+ graph.Append(';');
+ }
+ if (audio)
+ {
+ graph.Append("[0:a:0]").Append(n > 1 ? $"asplit={n}" : "anull");
+ for (var k = 0; k < n; k++)
+ graph.Append($"[as{k}]");
+ graph.Append(';');
+ }
+
+ var picture = $"fps={fps},scale={width}:{height},setsar=1,format={pixelFormat}";
+ var source = 0;
+ for (var i = 0; i < edit.Parts.Count; i++)
+ {
+ var part = edit.Parts[i];
+ var length = Seconds(part.Length);
+ if (part.Blank)
+ {
+ if (video)
+ graph.Append($"color=c=black:s={width}x{height}:r={fps}:d={length},setsar=1,format={pixelFormat}[v{i}];");
+ if (audio)
+ graph.Append($"anullsrc=r=48000:cl=stereo,atrim=duration={length}[a{i}];");
+ continue;
+ }
+ var start = Seconds(part.Start - from);
+ var end = Seconds(part.End - from);
+ var speed = part.PlaySpeed;
+ var setpts = speed == 1 ? "setpts=PTS-STARTPTS" : $"setpts=(PTS-STARTPTS)/{Seconds(speed)}";
+ if (video)
+ graph.Append($"[vs{source}]trim=start={start}:end={end},{setpts},{picture}[v{i}];");
+ if (audio)
+ {
+ graph.Append($"[as{source}]atrim=start={start}:end={end},asetpts=PTS-STARTPTS");
+ foreach (var tempo in Tempo(speed))
+ graph.Append($",atempo={Seconds(tempo)}");
+ if (part.PlayVolume != 1)
+ graph.Append($",volume={Seconds(part.PlayVolume)}");
+ graph.Append($",{ClipAudioFormat}[a{i}];");
+ }
+ source++;
+ }
+
+ for (var i = 0; i < edit.Parts.Count; i++)
+ graph.Append(video ? $"[v{i}]" : "").Append(audio ? $"[a{i}]" : "");
+ graph.Append($"concat=n={edit.Parts.Count}:v={(video ? 1 : 0)}:a={(audio ? 1 : 0)}{(video ? "[vc]" : "")}{(audio ? "[ac]" : "")};");
+
+ var total = edit.Length;
+ var fadeIn = Math.Min(ClipEdit.Fade(edit.FadeIn), total / 2);
+ var fadeOut = Math.Min(ClipEdit.Fade(edit.FadeOut), total / 2);
+ var outputs = new List();
+ if (video)
+ outputs.Add("[vc]" + Fades("fade", fadeIn, fadeOut, total) + "[v]");
+ if (song is not null)
+ {
+ // The music: as long as it plays, at its volume, faded at its ends, delayed to its place, under the sound.
+ if (!audio)
+ outputs.Add($"anullsrc=r=48000:cl=stereo,atrim=duration={Seconds(total)}[ac]");
+ var length = song.PlayLength(total);
+ var fade = song.Fade ? Math.Min(ClipMusic.FadeSeconds, length / 2) : 0;
+ var delay = (long)Math.Round(song.Offset * 1000);
+ var chain = $"[1:a:0]atrim=duration={Seconds(length)},asetpts=PTS-STARTPTS";
+ if (song.PlayVolume != 1)
+ chain += $",volume={Seconds(song.PlayVolume)}";
+ if (fade > 0)
+ chain += "," + Fades("afade", fade, fade, length);
+ if (delay > 0)
+ chain += $",adelay={delay}:all=1";
+ outputs.Add($"{chain},{ClipAudioFormat}[m]");
+ outputs.Add("[ac][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[am]");
+ outputs.Add("[am]" + Fades("afade", fadeIn, fadeOut, total) + "[a]");
+ }
+ else if (audio)
+ outputs.Add("[ac]" + Fades("afade", fadeIn, fadeOut, total) + "[a]");
+ graph.Append(string.Join(';', outputs));
+ return new ClipGraph(inputs, graph.ToString(), sound, total, video);
+ }
+
+ ///
+ /// The arguments that write of as ,
+ /// encoded with the preset's quality, largest height and codec, at the video's size and frame rate.
+ ///
+ /// The file has no video stream.
+ /// FFmpeg cannot write .
+ internal static List ClipEditArguments(string input, string output, string target, ClipEdit edit, MediaOptions options,
+ MediaInfo info, IReadOnlySet? encoders = null)
+ {
+ var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ if (!VideoTargets.TryGetValue(target, out var spec))
+ throw new NotSupportedException($"FFmpeg cannot write '{target}'.");
+ var (width, height) = ClipSize(video, options.MaxHeight);
+ var fps = NearestRate(video.FrameRate) ?? "30";
+ var encoder = VideoEncoder(target, spec.VideoCodec!, options.VideoCodec, encoders);
+ var tenBit = video.IsHighBitDepth && encoder is "libx265" or "libsvtav1" or "libaom-av1";
+ // "Remove audio" leaves the clip silent, music and all.
+ var graph = ClipGraphOf(input, edit, !options.RemoveAudio && info.Audio is not null, width, height, fps, tenBit ? "yuv420p10le" : "yuv420p",
+ music: !options.RemoveAudio);
+ var audio = graph.HasAudio;
+ var pal = IsPal(video.FrameRate);
+ var filter = target is "vob" or "dv"
+ ? graph.Filter.Replace("[v]", "[vd]", StringComparison.Ordinal) + $";[vd]{DvdOrDvFilter(video, pal)}[v]"
+ : graph.Filter;
+
+ List args = [.. Common, .. graph.Inputs, "-filter_complex", filter, "-map", "[v]"];
+ args.AddRange(VideoCodecArguments(target, encoder, options.Quality, video, pal));
+ if (audio)
+ {
+ args.AddRange(["-map", "[a]"]);
+ args.AddRange(AudioCodecArguments(target, spec.AudioCodec, options, new AudioStreamInfo(2, 48000), inVideo: true));
+ }
+ else
+ args.Add("-an");
+ args.AddRange(MetadataArguments());
+ args.AddRange(ContainerArguments(target));
+ args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]);
+ return args;
+ }
+
+ ///
+ /// The arguments that play in the clip window: its pictures
+ /// pixels high as raw BGRA frames at on stdout.
+ ///
+ internal static List ClipPreviewVideo(string input, ClipEdit edit, VideoStreamInfo video, int height, int fps,
+ out int width, out int frameHeight)
+ {
+ (width, frameHeight) = ClipSize(video, height);
+ var graph = ClipGraphOf(input, edit, false, width, frameHeight, fps.ToString(CultureInfo.InvariantCulture), "bgra");
+ return [.. Common, .. graph.Inputs, "-filter_complex", graph.Filter, "-map", "[v]", "-f", "rawvideo", "-pix_fmt", "bgra", "pipe:1"];
+ }
+
+ /// The arguments that play the sound of : a WAV stream on stdout (for ffplay).
+ /// The video has sound of its own (else only the music plays).
+ internal static List ClipPreviewAudio(string input, ClipEdit edit, VideoStreamInfo video, bool hasAudio = true)
+ {
+ var (width, height) = ClipSize(video, 90);
+ var graph = ClipGraphOf(input, edit, hasAudio, width, height, "10", video: false);
+ return [.. Common, .. graph.Inputs, "-filter_complex", graph.Filter, "-map", "[a]", "-c:a", "pcm_s16le", "-f", "wav", "pipe:1"];
+ }
+
+ ///
+ /// The size of a clip's pictures: as the video is shown (pixel aspect and rotation applied), scaled down to
+ /// (0: kept), even sides.
+ ///
+ internal static (int Width, int Height) ClipSize(VideoStreamInfo video, int maxHeight)
+ {
+ var width = video.Width * (video.SampleAspect > 0 ? video.SampleAspect : 1);
+ double height = video.Height;
+ if (video.IsRotated)
+ (width, height) = (height, width);
+ var scale = maxHeight > 0 && height > maxHeight ? maxHeight / height : 1;
+ return (Even(Math.Max(16, width * scale)), Even(Math.Max(16, height * scale)));
+ }
+
+ ///
+ /// The atempo steps for : each between 0.5 and 2, which every FFmpeg takes (0.25 is
+ /// 0.5 twice); none for 1.
+ ///
+ internal static IReadOnlyList Tempo(double speed)
+ {
+ var steps = new List();
+ var rest = speed;
+ while (rest > 2 + 1e-9)
+ {
+ steps.Add(2);
+ rest /= 2;
+ }
+ while (rest < 0.5 - 1e-9)
+ {
+ steps.Add(0.5);
+ rest /= 0.5;
+ }
+ if (Math.Abs(rest - 1) > 1e-9)
+ steps.Add(rest);
+ return steps;
+ }
+
+ /// The fade in and out of (fade or afade), or a pass-through when there is none.
+ private static string Fades(string filter, double fadeIn, double fadeOut, double total)
+ {
+ var steps = new List();
+ if (fadeIn > 0)
+ steps.Add($"{filter}=t=in:st=0:d={Seconds(fadeIn)}");
+ if (fadeOut > 0)
+ steps.Add($"{filter}=t=out:st={Seconds(Math.Max(0, total - fadeOut))}:d={Seconds(fadeOut)}");
+ return steps.Count > 0 ? string.Join(',', steps) : filter == "fade" ? "null" : "anull";
+ }
+
+ /// A number as FFmpeg's filters read it: up to three decimals, a point, no thousands separator.
+ private static string Seconds(double value) => Math.Round(Math.Max(0, value), 3).ToString("0.###", CultureInfo.InvariantCulture);
+}
diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs
index 5a3511306..44c98032b 100644
--- a/src/Filee.Engines/Media/MediaEncoding.cs
+++ b/src/Filee.Engines/Media/MediaEncoding.cs
@@ -19,7 +19,7 @@ internal sealed record MediaTarget(string Muxer, string? VideoCodec, string Audi
internal sealed record FfmpegPass(IReadOnlyList Arguments, double Weight);
/// Encoding choices and command lines.
-internal static class MediaEncoding
+internal static partial class MediaEncoding
{
///
/// Video formats Filee writes. H.264 + AAC wherever the container allows it: it plays on every browser, phone,
@@ -146,10 +146,17 @@ private static readonly (int Num, int Den)[] BroadcastRates =
/// x265 / SVT-AV1 (also the retry after a hardware encode failed).
///
/// Playback speed of an animated GIF or APNG (ImageOptions.PlaybackSpeed); 1 keeps the video's.
+ ///
+ /// A video target: only this part of the video, cut exactly (the video is always encoded;
+ /// copies it instead, cut at key frames).
+ ///
/// The file lacks the stream the target needs (e.g. no audio track).
public static IReadOnlyList Plan(string input, string output, string target, MediaOptions options, MediaInfo info, string workDirectory,
- CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null, double animationSpeed = 1)
+ CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null, double animationSpeed = 1,
+ ClipRange? clip = null)
{
+ if (clip is { } part && VideoTargets.TryGetValue(target, out var clipTarget))
+ return [new FfmpegPass(Video(input, output, target, clipTarget, options, info, encoders, hardware, part), 1)];
if (target == "gif")
return AnimatedGif(input, output, options, info, workDirectory, animationSpeed);
if (target == "apng")
@@ -215,9 +222,10 @@ public static List FrameSequence(
/// does not ask for hardware encoding, the target is written with another codec (VP9, MPEG-2, …), or the video is
/// copied anyway.
///
- internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info) =>
+ /// The video is encoded even where it could be copied (an exact clip).
+ internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info, bool encode = false) =>
options.HardwareEncoding && info.Video is { } video && VideoTargets.ContainsKey(target)
- && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && (info.RawStream || !CanCopyVideo(target, video, options))
+ && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && (encode || info.RawStream || !CanCopyVideo(target, video, options))
? MediaCodecs.Effective(target, options.VideoCodec) switch
{
VideoCodec.Hevc => "hevc",
@@ -227,27 +235,29 @@ public static List FrameSequence(
: null;
private static List Video(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info,
- IReadOnlySet? encoders, HardwareEncoder? hardware)
+ IReadOnlySet? encoders, HardwareEncoder? hardware, ClipRange? clip = null)
{
var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
// An input without sound is fine: the output simply has no audio track. Every track is kept (languages,
- // commentary, issue #396), except in containers that hold one.
+ // commentary, issue #396), except in containers that hold one and in a clip, which keeps the first one as a
+ // copied or edited clip does.
IReadOnlyList tracks = options.RemoveAudio ? []
- : SingleAudioTargets.Contains(target) ? [.. info.AudioTracks.Take(1)]
+ : clip is not null || SingleAudioTargets.Contains(target) ? [.. info.AudioTracks.Take(1)]
: info.AudioTracks;
- List args = [.. Common, "-i", input, "-map", "0:V:0"];
+ // A clip: FFmpeg seeks the input and decodes from the key frame before the start, so the first frame is exact.
+ List args = [.. Common, .. clip is { } part ? ClipInput(part) : [], "-i", input, "-map", "0:V:0"];
for (var i = 0; i < tracks.Count; i++)
args.AddRange(["-map", $"0:a:{i}"]);
- if (!info.RawStream && CanCopyVideo(target, video, options))
+ if (clip is null && !info.RawStream && CanCopyVideo(target, video, options))
{
args.AddRange(["-c:v", "copy"]);
// Apple plays HEVC in MP4/MOV only under the "hvc1" tag (FFmpeg keeps "hev1" from some sources).
if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov")
args.AddRange(["-tag:v", "hvc1"]);
}
- else if (hardware is not null && HardwareCodec(target, options, info) == hardware.Codec)
+ else if (hardware is not null && HardwareCodec(target, options, info, encode: clip is not null) == hardware.Codec)
{
// VA-API's device is a global option, so it goes before the input.
args.InsertRange(Common.Length, HardwareVideo.InputArguments(hardware));
@@ -265,8 +275,9 @@ private static List Video(string input, string output, string target, Me
args.Add("-an");
for (var i = 0; i < tracks.Count; i++)
{
- // Each track on its own: copied where the container takes it, else encoded for its own channel count.
- var trackArgs = CanCopyAudio(target, tracks[i], options)
+ // Each track on its own: copied where the container takes it, else encoded for its own channel count. A
+ // clip's sound is encoded too: copied, it would start with the packets before the seek point (a second early).
+ var trackArgs = clip is null && CanCopyAudio(target, tracks[i], options)
? ["-c:a", "copy"]
: AudioCodecArguments(target, spec.AudioCodec, options, tracks[i], inVideo: true);
args.AddRange(tracks.Count == 1 ? trackArgs : ForAudioTrack(trackArgs, i));
@@ -293,6 +304,56 @@ internal static IEnumerable ForAudioTrack(IReadOnlyList args, in
_ => arg,
});
+ ///
+ /// A part of a video copied as it is (-c copy): done in a moment and nothing is lost, but a copy can only
+ /// start at a key frame, so the clip starts at the last one before (up to a few
+ /// seconds early). The first video stream and the first audio track are kept (none with
+ /// ), as in an exact or edited clip; the
+ /// other options only apply to an exact clip, which is encoded.
+ ///
+ /// The file has no video stream.
+ /// FFmpeg cannot write .
+ internal static List ClipCopy(string input, string output, string target, ClipRange clip, MediaOptions options, MediaInfo info)
+ {
+ var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ if (!VideoTargets.TryGetValue(target, out var spec))
+ throw new NotSupportedException($"FFmpeg cannot write '{target}'.");
+ var audio = !options.RemoveAudio && info.Audio is not null;
+
+ List args = [.. Common, .. ClipInput(clip), "-i", input, "-map", "0:V:0"];
+ if (audio)
+ args.AddRange(["-map", "0:a:0"]);
+ args.AddRange(["-c", "copy"]);
+ // Apple plays HEVC in MP4/MOV only under the "hvc1" tag (FFmpeg keeps "hev1" from some sources).
+ if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov")
+ args.AddRange(["-tag:v", "hvc1"]);
+ if (!audio)
+ args.Add("-an");
+ // The copied packets keep their timestamps from the key frame on: shifted so the clip starts at 0.
+ args.AddRange(["-avoid_negative_ts", "make_zero"]);
+ args.AddRange(MetadataArguments());
+ args.AddRange(ContainerArguments(target));
+ args.AddRange(["-max_muxing_queue_size", "4096", "-f", spec.Muxer, "-progress", "pipe:1", output]);
+ return args;
+ }
+
+ ///
+ /// Input options that read only of the file: -ss and -to before -i,
+ /// both positions in the source.
+ ///
+ internal static string[] ClipInput(ClipRange clip) => ["-ss", ClipRange.Ffmpeg(clip.Start), "-to", ClipRange.Ffmpeg(clip.End)];
+
+ ///
+ /// One frame at as a JPEG at most pixels high (square
+ /// pixels, turned upright): the timeline and the large picture of the clip window.
+ ///
+ internal static List FrameAt(string input, double seconds, string output, int maxHeight, VideoStreamInfo? video) =>
+ [
+ .. Common, "-ss", ClipRange.Ffmpeg(seconds), "-i", input, "-map", "0:V:0",
+ "-vf", (video is null ? "" : SquarePixels(video)) + $"scale=w=-2:h='trunc(min(ih,{Math.Max(16, maxHeight)})/2)*2'",
+ "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", "-q:v", "3", "-f", "image2", output,
+ ];
+
///
/// Codecs a container takes as they are. A video whose streams are listed for the target is only rewrapped
/// (stream copy): no quality loss, HDR and 10-bit kept, seconds instead of minutes. iPhone videos (HEVC or H.264
diff --git a/src/Filee.Engines/Media/VideoFrames.cs b/src/Filee.Engines/Media/VideoFrames.cs
new file mode 100644
index 000000000..2bb60eb87
--- /dev/null
+++ b/src/Filee.Engines/Media/VideoFrames.cs
@@ -0,0 +1,58 @@
+// What the clip window ("Cut a clip") needs of a video before anything is cut: how long it is and pictures of single
+// frames for its timeline and its large preview, taken with Filee's FFmpeg into the window's own work folder.
+
+namespace Filee.Engines.Media;
+
+/// A video as the clip window shows it.
+/// Playing time in seconds, 0 when the file doesn't say.
+/// Width of the picture as shown (pixel aspect and rotation applied).
+/// Height of the picture as shown.
+/// Frames per second, 0 when unknown.
+public sealed record VideoSummary(double Duration, int Width, int Height, double FrameRate)
+{
+ /// One frame's length in seconds (a 30 fps one when the rate is unknown).
+ public double FrameLength => FrameRate is > 1 and < 1000 ? 1 / FrameRate : 1 / 30.0;
+
+ ///
+ /// The last time a frame can be taken at: a frame before the end, as FFmpeg finds nothing to show at the end
+ /// itself.
+ ///
+ public double LastFrameTime => Math.Max(0, Duration - FrameLength);
+}
+
+/// Reads a video's length and takes pictures of single frames with Filee's FFmpeg.
+public static class VideoFrames
+{
+ /// How long one frame may take: FFmpeg seeks to the key frame before it and decodes from there.
+ private static readonly TimeSpan FrameStall = TimeSpan.FromSeconds(30);
+
+ /// True when FFmpeg is installed (Settings › Engines).
+ public static bool IsAvailable => FfmpegConverter.Locate() is { } tools && File.Exists(tools.Ffmpeg) && File.Exists(tools.Ffprobe);
+
+ /// The length and size of the video at .
+ /// FFmpeg is missing, the file can't be read or has no video.
+ public static async Task ProbeAsync(string path, CancellationToken cancellationToken)
+ {
+ var (_, ffprobe) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var info = await MediaInfo.ProbeAsync(ffprobe, Path.GetFullPath(path), cancellationToken);
+ var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ var height = video.IsRotated ? video.Width : video.Height;
+ return new VideoSummary(info.Duration?.TotalSeconds ?? 0, (int)Math.Round(height * video.DisplayAspect), height, video.FrameRate);
+ }
+
+ ///
+ /// Writes the frame at as a JPEG at most pixels high to
+ /// . False when FFmpeg found no frame there (past the end) or failed.
+ ///
+ public static async Task WriteFrameAsync(string path, double seconds, string output, int maxHeight, CancellationToken cancellationToken)
+ {
+ var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ var arguments = MediaEncoding.FrameAt(Path.GetFullPath(path), Math.Max(0, seconds), output, maxHeight, null);
+ var result = await FfmpegRunner.RunAsync(ffmpeg, arguments, null, cancellationToken, FrameStall, background: true);
+ return result.ExitCode == 0 && File.Exists(output) && new FileInfo(output).Length > 0;
+ }
+
+ /// The time of the timeline's picture of : the middle of its part.
+ public static double ThumbnailTime(int index, int count, double duration) =>
+ count <= 0 || duration <= 0 ? 0 : duration * (index + 0.5) / count;
+}
diff --git a/tests/Filee.App.Tests/ClipWindowTests.cs b/tests/Filee.App.Tests/ClipWindowTests.cs
new file mode 100644
index 000000000..36b742450
--- /dev/null
+++ b/tests/Filee.App.Tests/ClipWindowTests.cs
@@ -0,0 +1,1027 @@
+// The "Cut a clip" window: the clip a video opens with, the handles moved by the timeline, the keys and the quick
+// lengths, the large picture of the active handle, and the conversion that Save starts. Frames come from a stand-in
+// for FFmpeg that paints a picture for each time, so these tests run without it (RenderTests has its screenshots).
+
+using System.Collections.Concurrent;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.Views;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using Filee.Engines.Media;
+using ImageMagick;
+using ImageMagick.Drawing;
+using static Filee.App.Tests.RenderTests;
+
+namespace Filee.App.Tests;
+
+public class ClipWindowTests
+{
+ /// A 5:12.4 video, as long as the highlights the tool is for come from.
+ private const double Length = 312.4;
+
+ [AvaloniaFact]
+ public void A_video_opens_on_its_first_30_seconds_with_the_timeline_filled()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, frames, _) =>
+ {
+ Assert.Equal((0.0, 30.0), (vm.Start, vm.End));
+ Assert.Equal(("0:00.0", "0:30.0", "0:30.0", "5:12.4"), (vm.StartText, vm.EndText, vm.LengthText, vm.DurationText));
+ Assert.Equal(ClipHandle.Start, vm.ActiveHandle);
+ Assert.Equal("Start 0:00.0", vm.PreviewLabel);
+ Assert.Equal(ClipWindowViewModel.ThumbnailCount, vm.Thumbnails.Count);
+ Assert.All(vm.Thumbnails, Assert.NotNull);
+ Assert.Same(vm.Thumbnails, window.ClipTimeline.Thumbnails);
+ // The large picture is the start's own frame, larger than a timeline picture.
+ Assert.Contains(0.0, frames.Times);
+ Assert.True(vm.Preview!.PixelSize.Height > vm.Thumbnails[0]!.PixelSize.Height);
+ Assert.False(vm.HasMultipleFiles);
+ Assert.Equal(AppHost.Get()["clip.save"], vm.SaveText);
+ Assert.True(vm.IsFast);
+ });
+ }
+
+ [AvaloniaFact]
+ public void A_short_video_opens_whole_and_offers_only_the_lengths_it_has()
+ {
+ WithClipWindow("en", false, ["short.mp4"], (_, vm, _, _) =>
+ {
+ Assert.Equal((0.0, 20.0), (vm.Start, vm.End));
+ Assert.True(vm.Can15);
+ Assert.False(vm.Can30);
+ Assert.False(vm.Can60);
+ }, duration: 20);
+ }
+
+ [AvaloniaFact]
+ public void Handles_stay_inside_the_video_and_in_order()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, _, _) =>
+ {
+ vm.MoveHandle(ClipHandle.End, 999);
+ Assert.Equal(Length, vm.End);
+ Assert.Equal(ClipHandle.End, vm.ActiveHandle);
+
+ vm.MoveHandle(ClipHandle.Start, 400);
+ Assert.Equal(Length - ClipRange.MinLength, vm.Start, 6);
+ Assert.Equal(ClipHandle.Start, vm.ActiveHandle);
+
+ vm.MoveHandle(ClipHandle.Start, -10);
+ Assert.Equal(0, vm.Start);
+ vm.MoveHandle(ClipHandle.End, 0);
+ Assert.Equal(ClipRange.MinLength, vm.End, 6);
+
+ // The whole clip moves without changing length, and stops at the ends.
+ vm.MoveHandle(ClipHandle.End, 20);
+ vm.MoveClip(300);
+ Assert.Equal((Length - 20, Length), (vm.Start, vm.End));
+ vm.MoveClip(-5);
+ Assert.Equal((0.0, 20.0), (vm.Start, vm.End));
+ });
+ }
+
+ [AvaloniaFact]
+ public void A_handle_moved_past_the_other_takes_the_clip_along()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, _, _) =>
+ {
+ // Put the start on the moment: the 30-second clip follows.
+ vm.MoveHandle(ClipHandle.Start, 100);
+ Assert.Equal((100.0, 130.0), (vm.Start, vm.End));
+ // Within the clip, only that end moves.
+ vm.MoveHandle(ClipHandle.Start, 110);
+ Assert.Equal((110.0, 130.0), (vm.Start, vm.End));
+ // The end before the start: the start goes back with it.
+ vm.MoveHandle(ClipHandle.End, 50);
+ Assert.Equal((30.0, 50.0), (vm.Start, vm.End));
+ // As far as the video goes.
+ vm.MoveHandle(ClipHandle.Start, 305);
+ Assert.Equal((305.0, Length), (vm.Start, vm.End));
+ vm.MoveHandle(ClipHandle.End, 3);
+ Assert.Equal((0.0, 3.0), (vm.Start, vm.End));
+ });
+ }
+
+ [AvaloniaFact]
+ public void Quick_lengths_run_from_the_start_or_end_with_the_video()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, _, _) =>
+ {
+ vm.MoveHandle(ClipHandle.Start, 100);
+ vm.QuickLengthCommand.Execute("60");
+ Assert.Equal((100.0, 160.0), (vm.Start, vm.End));
+ Assert.Equal(ClipHandle.End, vm.ActiveHandle); // to see where it now ends
+ vm.QuickLengthCommand.Execute("15");
+ Assert.Equal((100.0, 115.0), (vm.Start, vm.End));
+
+ // Near the end: the video's last 60 seconds.
+ vm.MoveHandle(ClipHandle.End, Length);
+ vm.MoveHandle(ClipHandle.Start, 300);
+ vm.SetLength(60);
+ Assert.Equal(Length - 60, vm.Start, 6);
+ Assert.Equal(Length, vm.End);
+ Assert.Equal("1:00.0", vm.LengthText);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Arrow_keys_move_the_active_handle_and_s_and_e_pick_it()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ Press(window, Key.Right);
+ Assert.Equal(0.1, vm.Start, 6);
+ Press(window, Key.Right, RawInputModifiers.Shift);
+ Assert.Equal(1.1, vm.Start, 6);
+ Press(window, Key.Left);
+ Assert.Equal(1.0, vm.Start, 6);
+
+ Press(window, Key.E);
+ Assert.Equal(ClipHandle.End, vm.ActiveHandle);
+ Assert.True(vm.IsEndActive);
+ Press(window, Key.Left, RawInputModifiers.Shift);
+ Assert.Equal(29.0, vm.End, 6);
+ Assert.Equal("End 0:29.0", vm.PreviewLabel);
+
+ Press(window, Key.S);
+ Assert.Equal(ClipHandle.Start, vm.ActiveHandle);
+ // Ctrl + arrow is no nudge.
+ Press(window, Key.Right, RawInputModifiers.Control);
+ Assert.Equal(1.0, vm.Start, 6);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Right_to_left_languages_keep_time_running_left_to_right()
+ {
+ WithClipWindow("ar", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ Assert.Equal(FlowDirection.RightToLeft, window.FlowDirection);
+ Assert.Equal(FlowDirection.LeftToRight, window.ClipTimeline.FlowDirection);
+ Press(window, Key.Right);
+ Assert.Equal(0.1, vm.Start, 6); // later, as on the timeline
+ var timeline = window.ClipTimeline;
+ Assert.True(timeline.XOf(vm.End) > timeline.XOf(vm.Start));
+ });
+ }
+
+ [AvaloniaFact]
+ public void Dragging_a_handle_on_the_timeline_moves_that_end_and_shows_its_frame()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, frames, _) =>
+ {
+ var timeline = window.ClipTimeline;
+ var origin = timeline.TranslatePoint(default, window)!.Value;
+ var y = timeline.Bounds.Height / 2;
+ var grab = new Point(timeline.XOf(vm.End) + 6, y) + (Vector)origin;
+ var drop = new Point(timeline.XOf(120), y) + (Vector)origin;
+
+ window.MouseDown(grab, MouseButton.Left);
+ window.MouseMove(drop);
+ window.MouseUp(drop, MouseButton.Left);
+ Pump();
+
+ Assert.Equal(ClipHandle.End, vm.ActiveHandle);
+ Assert.InRange(vm.End, 117, 123);
+ Assert.Equal(0, vm.Start);
+ // The end's frame: the clip's last one, just before the end.
+ WaitFor(() => frames.Times.Any(t => Math.Abs(t - (vm.End - 1 / 30.0)) < 1e-6));
+
+ // Dragging the middle moves the whole clip.
+ var length = vm.End - vm.Start;
+ var middle = new Point(timeline.XOf(vm.Start + length / 2), y) + (Vector)origin;
+ window.MouseDown(middle, MouseButton.Left);
+ window.MouseMove(middle + new Vector(timeline.XOf(60) - timeline.XOf(0), 0));
+ window.MouseUp(middle, MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.Start, 57, 63);
+ Assert.Equal(length, vm.End - vm.Start, 6);
+
+ // Pressing beside the clip brings the nearer handle there.
+ var before = new Point(timeline.XOf(20), y) + (Vector)origin;
+ window.MouseDown(before, MouseButton.Left);
+ window.MouseUp(before, MouseButton.Left);
+ Pump();
+ Assert.Equal(ClipHandle.Start, vm.ActiveHandle);
+ Assert.InRange(vm.Start, 17, 23);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Save_starts_the_conversion_of_the_clip_and_closes()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, exports) =>
+ {
+ vm.MoveHandle(ClipHandle.Start, 65.3);
+ vm.QuickLengthCommand.Execute("30");
+ vm.IsFast = false;
+ Press(window, Key.Enter);
+
+ var (path, preset) = Assert.Single(exports);
+ Assert.EndsWith("match.mp4", path);
+ Assert.Equal(PresetKind.Convert, preset.Kind);
+ Assert.Equal("mkv", preset.TargetFormat); // what targetFor says for the file
+ var part = Assert.Single(preset.Media.Clip!.Parts);
+ Assert.Equal(65.3, part.Start, 6);
+ Assert.Equal(95.3, part.End, 6);
+ Assert.True(preset.Media.ExactCut);
+ Assert.Equal("{name}_clip", preset.Output.FileNamePattern);
+ Assert.Equal("preset.clip", preset.NameKey);
+ WaitFor(() => !window.IsVisible);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Fast_or_exact_is_remembered_in_the_tool()
+ {
+ TestServices.EnsureInitialized("en");
+ var store = AppHost.Get();
+ var saved = store.FindPreset("tool-clip")!;
+ try
+ {
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, _, _) =>
+ {
+ vm.IsFast = false;
+ Assert.True(saved.Media.ExactCut);
+ }, tool: saved);
+ // Opened again: exact, as left.
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, _, _) =>
+ {
+ Assert.False(vm.IsFast);
+ vm.IsFast = true;
+ }, tool: saved);
+ Assert.False(saved.Media.ExactCut);
+ }
+ finally
+ {
+ saved.Media.ExactCut = false;
+ }
+ }
+
+ [AvaloniaFact]
+ public void Several_videos_follow_each_other_and_ones_that_cant_be_read_are_listed()
+ {
+ WithClipWindow("en", false, ["one.mp4", "broken.mp4", "three.mp4"], (window, vm, frames, exports) =>
+ {
+ Assert.True(vm.HasMultipleFiles);
+ Assert.Equal("1 of 3", vm.CounterText);
+ Assert.Equal(AppHost.Get()["clip.save_next"], vm.SaveText);
+
+ _ = vm.SaveAsync();
+ // The second one can't be read: noted, and the third opens.
+ WaitFor(() => vm.CurrentIndex == 2 && !vm.IsLoading && vm.Thumbnails.All(t => t is not null));
+ Assert.True(vm.HasStatusNotice);
+ Assert.Contains("broken.mp4", vm.StatusNotice);
+ Assert.Equal(AppHost.Get()["clip.save"], vm.SaveText);
+
+ _ = vm.SkipAsync();
+ WaitFor(() => vm.IsFinished);
+ Assert.Single(exports);
+ Assert.Single(vm.Problems);
+ Assert.Equal("1 of 3 failed", vm.Summary);
+ Assert.True(window.IsVisible); // stays on what went wrong until closed
+ Press(window, Key.Escape);
+ WaitFor(() => !window.IsVisible);
+ });
+ }
+
+ // ───────────────────────── Editing ─────────────────────────
+
+ [AvaloniaFact]
+ public void The_blade_cuts_at_the_playhead_and_delete_closes_up_with_the_magnet()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, exports) =>
+ {
+ vm.MovePlayhead(10);
+ Press(window, Key.C);
+ vm.MovePlayhead(20);
+ Press(window, Key.C);
+ Assert.Equal(3, vm.Pieces.Count);
+ Assert.Equal(2, vm.SelectedIndex); // the piece right of the last cut
+
+ // The middle piece out: a gap stays, with a button to close it.
+ vm.MovePlayhead(15);
+ Assert.Equal(1, vm.CurrentPiece);
+ Assert.Equal("Delete", vm.DeleteText);
+ Press(window, Key.Delete);
+ Assert.Equal(ClipPieceState.Blank, vm.Pieces[1].State);
+ Assert.True(vm.HasGaps);
+ Assert.Equal("0:30.0", vm.LengthText);
+ Assert.Equal("Close gap", vm.DeleteText);
+ Assert.False(vm.CanFast); // two stretches: encoded
+ Assert.True(vm.CanUndo);
+
+ // Delete on the gap closes it; again puts the piece back.
+ Press(window, Key.Delete);
+ Assert.Equal(ClipPieceState.Removed, vm.Pieces[1].State);
+ Assert.Equal("0:20.0", vm.LengthText);
+ Assert.Equal("Put back", vm.DeleteText);
+ Press(window, Key.Delete);
+ Assert.True(vm.Pieces[1].IsKept);
+
+ // Shift+Delete closes up at once; the gap button closes one gap.
+ Press(window, Key.Delete, RawInputModifiers.Shift);
+ Assert.Equal(ClipPieceState.Removed, vm.Pieces[1].State);
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Press(window, Key.Delete);
+ Assert.True(vm.HasGaps);
+ vm.CloseGapsCommand.Execute(null);
+ Assert.False(vm.HasGaps);
+ Assert.Equal("0:20.0", vm.LengthText);
+
+ // Undo with Ctrl+Z, redo with Ctrl+Y.
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Assert.True(vm.HasGaps);
+ Press(window, Key.Y, RawInputModifiers.Control);
+ Assert.False(vm.HasGaps);
+
+ // Saving writes the two pieces.
+ Press(window, Key.Enter);
+ var (_, preset) = Assert.Single(exports);
+ Assert.Equal([(0.0, 10.0), (20.0, 30.0)], preset.Media.Clip!.Parts.Select(p => (p.Start, p.End)));
+ });
+ }
+
+ [AvaloniaFact]
+ public void A_cut_needs_the_playhead_inside_the_clip()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ vm.MovePlayhead(100);
+ Press(window, Key.C);
+ Assert.Single(vm.Pieces);
+ Assert.Equal(AppHost.Get()["clip.cannot_cut"], vm.Message);
+ Assert.True(vm.HasMessage);
+ // The only piece can't be cut out.
+ vm.MovePlayhead(5);
+ Press(window, Key.Delete);
+ Assert.Equal(AppHost.Get()["clip.last_piece"], vm.Message);
+ Assert.True(vm.Pieces[0].IsKept);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Volume_speed_and_fades_change_the_piece_and_the_clip()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, exports) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ vm.SelectedVolume = 1.5;
+ Assert.Equal("150 %", vm.VolumeText);
+ vm.SelectedSpeed = vm.Speeds.Single(s => s.Value == 2);
+ Assert.Equal((1.5, 2.0), (vm.Pieces[1].Volume, vm.Pieces[1].Speed));
+ Assert.Equal((1.0, 1.0), (vm.Pieces[0].Volume, vm.Pieces[0].Speed));
+ Assert.Equal("0:20.0", vm.LengthText); // 10 s + 20 s at double speed
+
+ // Another piece shows its own values.
+ vm.MovePlayhead(5);
+ Assert.Equal(1, vm.SelectedVolume);
+ Assert.Equal(1, vm.SelectedSpeed!.Value);
+
+ vm.HasFadeIn = true;
+ vm.HasFadeOut = true;
+ Assert.True(window.ClipTimeline.FadeIn && window.ClipTimeline.FadeOut);
+ Assert.False(vm.CanFast);
+
+ Press(window, Key.Enter);
+ var clip = Assert.Single(exports).Preset.Media.Clip!;
+ Assert.Equal((ClipWindowViewModel.FadeSeconds, ClipWindowViewModel.FadeSeconds), (clip.FadeIn, clip.FadeOut));
+ Assert.Equal([(1.0, 1.0), (1.5, 2.0)], clip.Parts.Select(p => (p.Volume, p.Speed)));
+ });
+ }
+
+ [AvaloniaFact]
+ public void Remove_silence_cuts_the_silent_stretches_out_and_says_how_many()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (_, vm, frames, _) =>
+ {
+ frames.Silences.AddRange([new ClipRange(5, 8), new ClipRange(20, 24)]);
+ _ = vm.RemoveSilenceAsync();
+ WaitFor(() => !vm.IsFindingSilence);
+
+ Assert.Equal(2, vm.Pieces.Count(p => p.State == ClipPieceState.Removed));
+ Assert.Equal(AppHost.Get().Format("clip.silence_removed", 2), vm.Message);
+ vm.Undo();
+ Assert.Single(vm.Pieces);
+
+ frames.Silences.Clear();
+ _ = vm.RemoveSilenceAsync();
+ WaitFor(() => !vm.IsFindingSilence);
+ Assert.Equal(AppHost.Get()["clip.no_silence"], vm.Message);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Space_plays_the_clip_as_it_will_be_written_from_the_playhead()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, frames, _) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ vm.MovePlayhead(5);
+ vm.Delete(closeUp: true); // the first ten seconds out
+ vm.MovePlayhead(12);
+
+ Press(window, Key.Space);
+ WaitFor(() => frames.Played.Count == 1 && !vm.IsPlaying);
+ var played = frames.Played[0];
+ // From the playhead, as the clip plays: 2 s into the second piece.
+ Assert.Equal((12.0, 30.0), (played.Parts[0].Start, played.Parts[^1].End));
+ Assert.Equal(ClipHandle.Playhead, vm.ActiveHandle);
+ // The playhead followed the pictures (12 at 25 a second).
+ Assert.Equal(12 + 11 / 25.0, vm.Playhead, 3);
+ Assert.StartsWith("Playhead", vm.PreviewLabel);
+
+ // Esc stops playing first, and only then closes.
+ frames.HoldPlaying = new TaskCompletionSource();
+ Press(window, Key.Space);
+ WaitFor(() => vm.IsPlaying && frames.Played.Count == 2);
+ Assert.Equal("Pause (Space)", vm.PlayToolTip);
+ Press(window, Key.Escape);
+ Assert.False(vm.IsPlaying);
+ Assert.True(window.IsVisible);
+ // Editing while playing stops it too.
+ Press(window, Key.Space);
+ WaitFor(() => vm.IsPlaying);
+ vm.MoveHandle(ClipHandle.End, 25);
+ Assert.False(vm.IsPlaying);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Clicking_the_ruler_or_the_clip_puts_the_playhead_there_and_the_magnet_snaps_it()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ var timeline = window.ClipTimeline;
+ var origin = timeline.TranslatePoint(default, window)!.Value;
+
+ // The ruler, a little beside the cut: snapped onto it.
+ var ruler = new Point(timeline.XOf(10) + 4, 6) + (Vector)origin;
+ window.MouseDown(ruler, MouseButton.Left);
+ window.MouseUp(ruler, MouseButton.Left);
+ Pump();
+ Assert.Equal(10, vm.Playhead);
+ Assert.Equal(ClipHandle.Playhead, vm.ActiveHandle);
+
+ // Without the magnet it stays where pressed.
+ vm.IsMagnet = false;
+ window.MouseDown(ruler, MouseButton.Left);
+ window.MouseUp(ruler, MouseButton.Left);
+ Pump();
+ Assert.True(vm.Playhead > 10.5, vm.Playhead.ToString());
+
+ // A click (no drag) in the clip: the playhead, and the piece there selected.
+ var inClip = new Point(timeline.XOf(25), timeline.Bounds.Height - 10) + (Vector)origin;
+ window.MouseDown(inClip, MouseButton.Left);
+ window.MouseUp(inClip, MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.Playhead, 24, 26);
+ Assert.Equal(1, vm.SelectedIndex);
+ Assert.Equal((0.0, 30.0), (vm.Start, vm.End)); // not moved
+ });
+ }
+
+ [AvaloniaFact]
+ public void Zooming_shows_the_clip_close_up_and_stays_put_while_a_handle_is_dragged()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ vm.MoveHandle(ClipHandle.Start, 100);
+ Assert.Equal((0.0, Length), (vm.ViewStart, vm.ViewEnd));
+ Press(window, Key.Z);
+ Assert.True(vm.IsZoomed);
+ // The 30-second clip with a little room either side.
+ Assert.Equal((100 - 4.5, 130 + 4.5), (vm.ViewStart, vm.ViewEnd));
+ Pump();
+ var timeline = window.ClipTimeline;
+ Assert.True(timeline.XOf(130) - timeline.XOf(100) > timeline.Bounds.Width * 0.7);
+ Assert.Equal("1:35.5", vm.ViewStartText);
+
+ // Dragging the end to the edge of the view: the view doesn't move under the pointer.
+ var origin = timeline.TranslatePoint(default, window)!.Value;
+ var y = timeline.Bounds.Height / 2;
+ var grab = new Point(timeline.XOf(130) + 6, y) + (Vector)origin;
+ var edge = new Point(timeline.Bounds.Width + 40, y) + (Vector)origin;
+ window.MouseDown(grab, MouseButton.Left);
+ window.MouseMove(edge);
+ window.MouseUp(edge, MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.End, 134, 134.5); // at the edge, less where the handle was grabbed
+ Assert.Equal((95.5, 134.5), (vm.ViewStart, vm.ViewEnd));
+ // A key past it fits the view again.
+ Press(window, Key.Right, RawInputModifiers.Shift);
+ Assert.True(vm.ViewEnd > vm.End);
+
+ Press(window, Key.Z);
+ Assert.Equal((0.0, Length), (vm.ViewStart, vm.ViewEnd));
+ });
+ }
+
+ // ───────────────────────── The sequence ─────────────────────────
+
+ [AvaloniaFact]
+ public void The_sequence_shows_the_clip_as_it_plays_and_its_gap_button_closes_the_gap()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ vm.MovePlayhead(20);
+ vm.Split();
+ vm.MovePlayhead(15);
+ vm.Delete(closeUp: false);
+ Pump();
+ var sequence = window.ClipSequence;
+ Assert.Equal(ClipWindowViewModel.DetailCount, vm.DetailThumbnails.Count);
+ Assert.Equal((0.0, 30.0), (vm.DetailStart, vm.DetailEnd));
+ // Three spans: the pieces and the gap between them.
+ Assert.Equal([(0, 0.0, 10.0), (1, 10.0, 20.0), (2, 20.0, 30.0)], sequence.Layout());
+
+ // The gap's button (in the middle of the gap) closes it: the last piece moves up.
+ var origin = sequence.TranslatePoint(default, window)!.Value;
+ var button = new Point(sequence.XOf(15), (sequence.VideoLane.Top + sequence.SoundLane.Bottom) / 2) + (Vector)origin;
+ window.MouseDown(button, MouseButton.Left);
+ window.MouseUp(button, MouseButton.Left);
+ Pump();
+ Assert.Equal(ClipPieceState.Removed, vm.Pieces[1].State);
+ Assert.Equal([(0, 0.0, 10.0), (2, 10.0, 20.0)], sequence.Layout());
+
+ // A click in the sequence puts the playhead there, in the video where that moment comes from.
+ var click = new Point(sequence.XOf(15), sequence.VideoLane.Center.Y) + (Vector)origin;
+ window.MouseDown(click, MouseButton.Left);
+ window.MouseUp(click, MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.Playhead, 24.5, 25.5);
+ Assert.InRange(vm.SequencePlayhead, 14.5, 15.5);
+ Assert.Equal(2, vm.SelectedIndex);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Dragging_a_pieces_edge_takes_back_what_was_cut_off()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ vm.MovePlayhead(20);
+ vm.Split();
+ vm.MovePlayhead(15);
+ vm.Delete(closeUp: true); // [0-10][10-20 out][20-30]
+ Pump();
+ var sequence = window.ClipSequence;
+ var origin = sequence.TranslatePoint(default, window)!.Value;
+ var scale = sequence.Content.Width / 20; // pixels a second at the start of the drag
+ var edge = new Point(sequence.XOf(10) - 2, sequence.VideoLane.Center.Y) + (Vector)origin;
+
+ window.MouseDown(edge, MouseButton.Left);
+ window.MouseMove(edge + new Vector(4 * scale, 0));
+ window.MouseUp(edge + new Vector(4 * scale, 0), MouseButton.Left);
+ Pump();
+
+ // Four seconds of the piece cut out are back, and the clip is four seconds longer.
+ Assert.InRange(vm.Pieces[0].End, 13.6, 14.4);
+ Assert.Equal(ClipPieceState.Removed, vm.Pieces[1].State);
+ Assert.InRange(vm.Length, 23.6, 24.4);
+ Assert.Equal(ClipHandle.Playhead, vm.ActiveHandle); // the frame at the edge shows
+ // One undo takes the whole drag back.
+ vm.Undo();
+ Assert.Equal(10, vm.Pieces[0].End);
+ });
+ }
+
+ [AvaloniaFact]
+ public void A1_is_the_pieces_sound_delete_there_mutes_it_and_its_line_sets_the_volume()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, exports) =>
+ {
+ vm.MovePlayhead(10);
+ vm.Split();
+ Pump();
+ var sequence = window.ClipSequence;
+ var origin = sequence.TranslatePoint(default, window)!.Value;
+ var sound = new Point(sequence.XOf(5), sequence.SoundLane.Top + 6) + (Vector)origin;
+ window.MouseDown(sound, MouseButton.Left);
+ window.MouseUp(sound, MouseButton.Left);
+ Pump();
+ Assert.Equal((ClipLane.Audio, 0), (vm.SelectedLane, vm.SelectedIndex));
+ Assert.Equal("Mute sound", vm.DeleteText);
+ Press(window, Key.Delete);
+ Assert.True(vm.Pieces[0].Muted);
+ Assert.True(vm.IsCurrentMuted);
+ Assert.Equal("0:30.0", vm.LengthText); // the picture still plays
+ Assert.Equal("Unmute", vm.DeleteText);
+ vm.IsCurrentMuted = false;
+ Assert.False(vm.Pieces[0].Muted);
+
+ // The volume line of the second piece, dragged up: louder.
+ var line = new Point(sequence.XOf(20), sequence.SoundLane.Center.Y) + (Vector)origin;
+ window.MouseDown(line, MouseButton.Left);
+ window.MouseMove(line - new Vector(0, sequence.SoundLane.Height / 4));
+ window.MouseUp(line - new Vector(0, sequence.SoundLane.Height / 4), MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.Pieces[1].Volume, 1.4, 1.6);
+
+ vm.IsCurrentMuted = true; // the second piece is the current one now
+ Press(window, Key.Enter);
+ var clip = Assert.Single(exports).Preset.Media.Clip!;
+ Assert.Equal([false, true], clip.Parts.Select(p => p.Muted));
+ });
+ }
+
+ [AvaloniaFact]
+ public void Music_goes_on_A2_moves_along_and_is_written_with_the_clip()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, frames, exports) =>
+ {
+ _ = vm.AddMusicAsync(Path.Combine("music", "song.mp3"));
+ WaitFor(() => vm.HasMusic && vm.MusicLoudness.Count > 0);
+ Assert.True(vm.IsMusicSelected);
+ Assert.Equal("Remove music", vm.DeleteText);
+ Assert.Equal(ClipMusic.DefaultVolume, vm.SelectedVolume);
+ vm.SelectedVolume = 0.4; // the slider is the music's now
+ Assert.Equal(0.4, vm.Music!.Volume);
+ Assert.False(vm.CanFast);
+ Pump();
+
+ // Dragged five seconds along.
+ var sequence = window.ClipSequence;
+ var origin = sequence.TranslatePoint(default, window)!.Value;
+ var scale = sequence.Content.Width / 30;
+ var block = new Point(sequence.XOf(10), sequence.MusicLane.Top + 6) + (Vector)origin;
+ window.MouseDown(block, MouseButton.Left);
+ window.MouseMove(block + new Vector(5 * scale, 0));
+ window.MouseUp(block + new Vector(5 * scale, 0), MouseButton.Left);
+ Pump();
+ Assert.InRange(vm.Music!.Offset, 4.7, 5.3);
+
+ // Playing plays it too; saving writes it.
+ Press(window, Key.Space);
+ WaitFor(() => frames.Played.Count == 1 && !vm.IsPlaying);
+ Assert.Equal("song.mp3", Path.GetFileName(frames.Played[0].Music!.Path));
+ Press(window, Key.Enter);
+ var music = Assert.Single(exports).Preset.Media.Clip!.Music!;
+ Assert.Equal((0.4, 40.0), (music.Volume, music.Length));
+ });
+ }
+
+ [AvaloniaFact]
+ public void Music_is_taken_away_with_delete_and_a_file_without_sound_is_refused()
+ {
+ WithClipWindow("en", false, ["match.mp4"], (window, vm, _, _) =>
+ {
+ _ = vm.AddMusicAsync(Path.Combine("music", "silent.mp4"));
+ WaitFor(() => vm.HasMessage);
+ Assert.False(vm.HasMusic);
+ Assert.Equal(AppHost.Get()["clip.no_music"], vm.Message);
+
+ _ = vm.AddMusicAsync(Path.Combine("music", "song.mp3"));
+ WaitFor(() => vm.HasMusic);
+ Press(window, Key.Delete);
+ Assert.False(vm.HasMusic);
+ Assert.Equal(ClipLane.Video, vm.SelectedLane);
+ vm.Undo();
+ Assert.True(vm.HasMusic);
+ });
+ }
+
+ // ───────────────────────── Several pieces, keys, busy ─────────────────────────
+
+ ///