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.35Z M12,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,7Z M7,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,4V3H19Z M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z M14,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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +