Initial release: friendly TUI for FFmpeg

File browser, stackable edits (trim, resize, crop, rotate, speed,
color, effects, fps, compress, convert, audio) with a live preview
of the generated ffmpeg command, progress bar with cancel, audio-aware
edit menus, and Catppuccin Macchiato theme.
This commit is contained in:
2026-06-11 11:23:01 +01:00
commit 471aa6fb0c
9 changed files with 2606 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+655
View File
@@ -0,0 +1,655 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compact_str"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyff"
version = "0.1.0"
dependencies = [
"anyhow",
"ratatui",
"serde_json",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lazyff"
version = "0.1.0"
edition = "2021"
description = "A friendly TUI for FFmpeg — trim, resize, crop, compress and convert without memorizing flags"
[dependencies]
ratatui = "0.29"
serde_json = "1"
anyhow = "1"
[profile.release]
lto = true
strip = true
+62
View File
@@ -0,0 +1,62 @@
# lazyff
A friendly terminal UI for FFmpeg. Trim, resize, crop, compress, convert and
apply effects to video and audio — without memorizing a single flag.
lazyff always shows the exact `ffmpeg` command it is about to run, so you can
learn FFmpeg as you use it (and copy-paste the command anywhere).
## Requirements
- `ffmpeg` and `ffprobe` on your PATH
- A terminal
## Install / run
```sh
cargo run --release
# or
cargo install --path .
lazyff
```
lazyff opens a file browser in the current directory. Pick a video or audio
file, then stack up edits and press `r`.
## What it can do
| Edit | Examples |
| --------------- | ----------------------------------------------------- |
| Trim / Cut | keep 0:30 → 1:45, instant lossless stream copy |
| Resize | 1080p/720p/480p presets, half size, custom |
| Crop | centered square / 16:9 / 9:16 / 4:3, custom rectangle |
| Rotate / Flip | 90°, 180°, mirror |
| Speed | 0.25x 4x, audio pitch preserved |
| Color adjust | brightness, contrast, saturation |
| Visual effects | grayscale, sepia, blur, sharpen, vignette, denoise, fades |
| Frame rate | 12 60 fps |
| Compress | H.264/H.265 with plain-English quality presets |
| Convert format | MP4, MKV, WebM, MOV, GIF (with palette pass), MP3, M4A, WAV |
| Audio | remove track, volume, loudness normalization |
Edits combine: add a trim, a resize and a compress, and lazyff builds one
ffmpeg command that does all three in a single pass.
Audio files (MP3, FLAC, WAV, ...) only offer the edits that make sense for
them — trim, speed, volume/fades, and conversion between audio formats
(MP3, M4A, FLAC, WAV, OGG).
## Keys
**File browser**`↑↓` move, `Enter` open, `Backspace` parent folder, `q` quit
**Editor**`a` add edit, `Enter` change, `d` delete, `J`/`K` reorder,
`o` output name, `r` run, `Esc` back to files, `q` quit
**Forms**`↑↓` field, `←→` change choice, type into text fields,
`Enter` save, `Esc` cancel
While encoding you get a progress bar with speed readout; `Esc` cancels and
removes the partial output. Output files are written next to the input as
`<name>_lazyff.<ext>` (rename with `o`); lazyff refuses to overwrite the
input file.
+487
View File
@@ -0,0 +1,487 @@
//! Application state and event handling.
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::widgets::ListState;
use crate::ffmpeg::{self, MediaInfo, RunMsg, Runner};
use crate::ops::{FieldValue, OpKind, Operation};
const MEDIA_EXTS: &[&str] = &[
"mp4", "mkv", "webm", "mov", "avi", "flv", "wmv", "ts", "m2ts", "m4v", "mpg", "mpeg", "gif",
"mp3", "wav", "flac", "aac", "ogg", "opus", "m4a", "wma", "3gp",
];
pub enum Screen {
Browser,
Editor,
}
pub enum Modal {
AddOp { selected: usize },
EditOp {
index: usize,
field: usize,
op: Operation,
/// True until the focused text field is typed into; the first
/// keystroke replaces the pre-filled value instead of appending.
pristine: bool,
},
Output { text: String },
Running,
Done { success: bool, title: String, message: String },
}
pub struct Entry {
pub name: String,
pub is_dir: bool,
}
pub struct RunState {
pub runner: Runner,
pub secs: f64,
pub speed: String,
pub expected: f64,
pub errors: Vec<String>,
pub output: PathBuf,
pub canceled: bool,
}
pub struct App {
pub should_quit: bool,
pub screen: Screen,
pub modal: Option<Modal>,
// file browser
pub cwd: PathBuf,
pub entries: Vec<Entry>,
pub browser_state: ListState,
pub browser_error: Option<String>,
// editor
pub input: Option<PathBuf>,
pub media: Option<MediaInfo>,
pub ops: Vec<Operation>,
pub op_state: ListState,
pub output_stem: String,
pub run: Option<RunState>,
}
impl App {
pub fn new() -> Result<App> {
let cwd = std::env::current_dir()?;
let mut app = App {
should_quit: false,
screen: Screen::Browser,
modal: None,
cwd,
entries: Vec::new(),
browser_state: ListState::default(),
browser_error: None,
input: None,
media: None,
ops: Vec::new(),
op_state: ListState::default(),
output_stem: String::new(),
run: None,
};
app.refresh_entries();
Ok(app)
}
pub fn refresh_entries(&mut self) {
self.entries.clear();
if self.cwd.parent().is_some() {
self.entries.push(Entry { name: "..".into(), is_dir: true });
}
let mut dirs = Vec::new();
let mut files = Vec::new();
if let Ok(rd) = std::fs::read_dir(&self.cwd) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
dirs.push(name);
} else {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if MEDIA_EXTS.contains(&ext.as_str()) {
files.push(name);
}
}
}
}
dirs.sort_unstable_by_key(|s| s.to_lowercase());
files.sort_unstable_by_key(|s| s.to_lowercase());
self.entries
.extend(dirs.into_iter().map(|name| Entry { name, is_dir: true }));
self.entries
.extend(files.into_iter().map(|name| Entry { name, is_dir: false }));
self.browser_state.select(if self.entries.is_empty() { None } else { Some(0) });
}
pub fn has_video(&self) -> bool {
self.media.as_ref().map(|m| m.video_codec.is_some()).unwrap_or(true)
}
/// Edits that apply to the current input: everything for video files,
/// only audio-relevant ones for audio files.
pub fn available_kinds(&self) -> Vec<OpKind> {
let has_video = self.has_video();
OpKind::ALL
.into_iter()
.filter(|k| has_video || !k.video_only())
.collect()
}
/// Drain progress messages from a running ffmpeg job.
pub fn poll_run_messages(&mut self) {
let Some(rs) = &mut self.run else { return };
let mut finished = false;
while let Ok(msg) = rs.runner.rx.recv_timeout(Duration::ZERO) {
match msg {
RunMsg::Progress { secs, speed } => {
rs.secs = secs;
rs.speed = speed;
}
RunMsg::Stderr(line) => {
rs.errors.push(line);
if rs.errors.len() > 30 {
rs.errors.remove(0);
}
}
RunMsg::Finished => finished = true,
}
}
if !finished {
return;
}
let mut rs = self.run.take().unwrap();
let status = rs.runner.child.wait();
let success = status.map(|s| s.success()).unwrap_or(false) && !rs.canceled;
if success {
let out_size = std::fs::metadata(&rs.output).map(|m| m.len()).unwrap_or(0);
let in_size = self.media.as_ref().map(|m| m.size_bytes).unwrap_or(0);
let mut message = format!(
"Saved {}\n\nSize: {}",
rs.output.display(),
ffmpeg::fmt_size(out_size)
);
if in_size > 0 {
message.push_str(&format!(
" (input was {}, {:.0}%)",
ffmpeg::fmt_size(in_size),
out_size as f64 / in_size as f64 * 100.0
));
}
self.modal = Some(Modal::Done { success: true, title: "Done".into(), message });
self.refresh_entries();
} else if rs.canceled {
let _ = std::fs::remove_file(&rs.output);
self.modal = Some(Modal::Done {
success: false,
title: "Canceled".into(),
message: "The encode was canceled and the partial output deleted.".into(),
});
} else {
let tail: Vec<String> = rs.errors.split_off(rs.errors.len().saturating_sub(12));
let message = if tail.is_empty() {
"ffmpeg failed without an error message.".into()
} else {
format!("ffmpeg reported:\n\n{}", tail.join("\n"))
};
self.modal = Some(Modal::Done { success: false, title: "Failed".into(), message });
}
}
pub fn on_key(&mut self, key: KeyEvent) {
if let Some(modal) = self.modal.take() {
self.modal = self.on_modal_key(modal, key);
return;
}
match self.screen {
Screen::Browser => self.on_browser_key(key),
Screen::Editor => self.on_editor_key(key),
}
}
// -- browser ------------------------------------------------------------
fn on_browser_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => move_sel(&mut self.browser_state, self.entries.len(), -1),
KeyCode::Down | KeyCode::Char('j') => move_sel(&mut self.browser_state, self.entries.len(), 1),
KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => self.go_parent(),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.open_selected(),
KeyCode::Esc => {
if self.input.is_some() {
self.screen = Screen::Editor;
}
}
_ => {}
}
}
fn go_parent(&mut self) {
if let Some(p) = self.cwd.parent() {
self.cwd = p.to_path_buf();
self.refresh_entries();
}
}
fn open_selected(&mut self) {
let Some(i) = self.browser_state.selected() else { return };
let Some(entry) = self.entries.get(i) else { return };
if entry.is_dir {
if entry.name == ".." {
self.go_parent();
} else {
self.cwd = self.cwd.join(&entry.name);
self.refresh_entries();
}
return;
}
let path = self.cwd.join(&entry.name);
match ffmpeg::probe(&path) {
Ok(info) => {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "output".into());
self.output_stem = format!("{}_lazyff", stem);
self.input = Some(path);
self.media = Some(info);
self.ops.clear();
self.op_state.select(None);
self.browser_error = None;
self.screen = Screen::Editor;
}
Err(e) => self.browser_error = Some(e.to_string()),
}
}
// -- editor -------------------------------------------------------------
fn on_editor_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Char('b') | KeyCode::Esc => {
self.screen = Screen::Browser;
self.refresh_entries();
}
KeyCode::Char('a') => self.modal = Some(Modal::AddOp { selected: 0 }),
KeyCode::Up | KeyCode::Char('k') => move_sel(&mut self.op_state, self.ops.len(), -1),
KeyCode::Down | KeyCode::Char('j') => move_sel(&mut self.op_state, self.ops.len(), 1),
KeyCode::Enter | KeyCode::Char('e') => {
if let Some(i) = self.op_state.selected() {
if i < self.ops.len() {
self.modal = Some(Modal::EditOp {
index: i,
field: 0,
op: self.ops[i].clone(),
pristine: true,
});
}
}
}
KeyCode::Char('d') | KeyCode::Delete => {
if let Some(i) = self.op_state.selected() {
if i < self.ops.len() {
self.ops.remove(i);
let len = self.ops.len();
self.op_state.select(if len == 0 {
None
} else {
Some(i.min(len - 1))
});
}
}
}
KeyCode::Char('K') => self.reorder(-1),
KeyCode::Char('J') => self.reorder(1),
KeyCode::Char('o') => {
self.modal = Some(Modal::Output { text: self.output_stem.clone() })
}
KeyCode::Char('r') => self.start_run(),
_ => {}
}
}
fn reorder(&mut self, dir: isize) {
let Some(i) = self.op_state.selected() else { return };
let j = i as isize + dir;
if j < 0 || j as usize >= self.ops.len() {
return;
}
self.ops.swap(i, j as usize);
self.op_state.select(Some(j as usize));
}
fn start_run(&mut self) {
let (Some(input), Some(media)) = (&self.input, &self.media) else { return };
let built = ffmpeg::build(input, &self.ops, &self.output_stem, media);
if Some(built.output.as_path()) == self.input.as_deref() {
self.modal = Some(Modal::Done {
success: false,
title: "Refusing to run".into(),
message: "Output would overwrite the input file. Change the output name with 'o'."
.into(),
});
return;
}
match ffmpeg::run(&built.args) {
Ok(runner) => {
self.run = Some(RunState {
runner,
secs: 0.0,
speed: String::new(),
expected: built.expected_duration,
errors: Vec::new(),
output: built.output,
canceled: false,
});
self.modal = Some(Modal::Running);
}
Err(e) => {
self.modal = Some(Modal::Done {
success: false,
title: "Failed".into(),
message: e.to_string(),
});
}
}
}
// -- modals ---------------------------------------------------------------
fn on_modal_key(&mut self, modal: Modal, key: KeyEvent) -> Option<Modal> {
match modal {
Modal::AddOp { mut selected } => {
let kinds = self.available_kinds();
match key.code {
KeyCode::Esc => None,
KeyCode::Up | KeyCode::Char('k') => {
selected = (selected + kinds.len() - 1) % kinds.len();
Some(Modal::AddOp { selected })
}
KeyCode::Down | KeyCode::Char('j') => {
selected = (selected + 1) % kinds.len();
Some(Modal::AddOp { selected })
}
KeyCode::Enter => {
let op = kinds[selected].build(self.has_video());
self.ops.push(op.clone());
let index = self.ops.len() - 1;
self.op_state.select(Some(index));
Some(Modal::EditOp { index, field: 0, op, pristine: true })
}
_ => Some(Modal::AddOp { selected }),
}
}
Modal::EditOp { index, mut field, mut op, mut pristine } => match key.code {
KeyCode::Esc => None,
KeyCode::Enter => {
if index < self.ops.len() {
self.ops[index] = op;
}
None
}
KeyCode::Up => {
field = (field + op.fields.len() - 1) % op.fields.len();
Some(Modal::EditOp { index, field, op, pristine: true })
}
KeyCode::Down | KeyCode::Tab => {
field = (field + 1) % op.fields.len();
Some(Modal::EditOp { index, field, op, pristine: true })
}
KeyCode::Left | KeyCode::Right => {
if let FieldValue::Choice { options, selected } = &mut op.fields[field].value {
let n = options.len();
*selected = if key.code == KeyCode::Left {
(*selected + n - 1) % n
} else {
(*selected + 1) % n
};
}
Some(Modal::EditOp { index, field, op, pristine })
}
KeyCode::Backspace => {
if let FieldValue::Text(s) = &mut op.fields[field].value {
s.pop();
pristine = false;
}
Some(Modal::EditOp { index, field, op, pristine })
}
KeyCode::Char(c) => {
if let FieldValue::Text(s) = &mut op.fields[field].value {
if pristine {
s.clear();
pristine = false;
}
if !c.is_control() && s.len() < 24 {
s.push(c);
}
}
Some(Modal::EditOp { index, field, op, pristine })
}
_ => Some(Modal::EditOp { index, field, op, pristine }),
},
Modal::Output { mut text } => match key.code {
KeyCode::Esc => None,
KeyCode::Enter => {
if !text.trim().is_empty() {
self.output_stem = text.trim().to_string();
}
None
}
KeyCode::Backspace => {
text.pop();
Some(Modal::Output { text })
}
KeyCode::Char(c) => {
if !c.is_control() && !"/\\".contains(c) && text.len() < 80 {
text.push(c);
}
Some(Modal::Output { text })
}
_ => Some(Modal::Output { text }),
},
Modal::Running => match key.code {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => {
if let Some(rs) = &mut self.run {
rs.canceled = true;
let _ = rs.runner.child.kill();
}
Some(Modal::Running)
}
_ => Some(Modal::Running),
},
Modal::Done { .. } => match key.code {
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => None,
_ => Some(modal),
},
}
}
}
fn move_sel(state: &mut ListState, len: usize, dir: isize) {
if len == 0 {
state.select(None);
return;
}
let cur = state.selected().unwrap_or(0) as isize;
let next = (cur + dir).clamp(0, len as isize - 1) as usize;
state.select(Some(next));
}
+590
View File
@@ -0,0 +1,590 @@
//! Everything that talks to ffmpeg/ffprobe: media probing, translating the
//! operation list into command-line arguments, and running the encode with
//! live progress reporting.
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use anyhow::{bail, Context, Result};
use crate::ops::{OpKind, Operation};
// ---------------------------------------------------------------------------
// Probing
pub struct MediaInfo {
pub duration: f64,
pub size_bytes: u64,
pub format: String,
pub width: Option<u64>,
pub height: Option<u64>,
pub fps: Option<f64>,
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
}
pub fn probe(path: &Path) -> Result<MediaInfo> {
let out = Command::new("ffprobe")
.args(["-v", "error", "-print_format", "json", "-show_format", "-show_streams"])
.arg(path)
.output()
.context("failed to run ffprobe")?;
if !out.status.success() {
bail!(
"ffprobe could not read this file:\n{}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
let fmt = &v["format"];
let duration = fmt["duration"].as_str().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0);
let size_bytes = fmt["size"].as_str().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let format = fmt["format_name"].as_str().unwrap_or("?").to_string();
let mut info = MediaInfo {
duration,
size_bytes,
format,
width: None,
height: None,
fps: None,
video_codec: None,
audio_codec: None,
};
for s in v["streams"].as_array().map(|a| a.as_slice()).unwrap_or(&[]) {
match s["codec_type"].as_str() {
Some("video") if info.video_codec.is_none() => {
// Cover art in audio files shows up as a video stream; skip it.
if s["disposition"]["attached_pic"].as_u64() == Some(1) {
continue;
}
info.video_codec = s["codec_name"].as_str().map(str::to_string);
info.width = s["width"].as_u64();
info.height = s["height"].as_u64();
info.fps = s["avg_frame_rate"]
.as_str()
.or(s["r_frame_rate"].as_str())
.and_then(parse_rate);
}
Some("audio") if info.audio_codec.is_none() => {
info.audio_codec = s["codec_name"].as_str().map(str::to_string);
}
_ => {}
}
}
Ok(info)
}
fn parse_rate(s: &str) -> Option<f64> {
let (num, den) = s.split_once('/')?;
let num: f64 = num.parse().ok()?;
let den: f64 = den.parse().ok()?;
if den == 0.0 || num == 0.0 {
None
} else {
Some(num / den)
}
}
// ---------------------------------------------------------------------------
// Command building
pub struct Built {
/// Arguments after `ffmpeg` (no progress/log plumbing — this is what the
/// preview shows and what `run` executes).
pub args: Vec<String>,
pub output: PathBuf,
/// Best guess at the output duration, used for the progress bar.
pub expected_duration: f64,
/// Human-readable remarks about choices the builder made.
pub notes: Vec<String>,
}
pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built {
let mut pre: Vec<String> = Vec::new(); // before -i
let mut vf: Vec<String> = Vec::new(); // video filter chain
let mut af: Vec<String> = Vec::new(); // audio filter chain
let mut out: Vec<String> = Vec::new(); // output options
let mut notes: Vec<String> = Vec::new();
let mut ext = input
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| "mp4".into());
let has_video = media.video_codec.is_some();
let has_audio = media.audio_codec.is_some();
let mut gif = false;
let mut audio_only_output = false;
let mut copy_requested = false;
let mut speed_factor = 1.0_f64;
let mut has_fps_op = false;
let mut has_resize_op = false;
// Trim is resolved first because the fade-out effect needs to know the
// trimmed duration.
let mut start = 0.0_f64;
let mut end = media.duration;
let mut explicit_end = false;
for op in ops.iter().filter(|o| o.kind == OpKind::Trim) {
if let Some(s) = parse_time(op.text(0)) {
start = s;
}
if let Some(e) = parse_time(op.text(1)) {
end = e;
explicit_end = true;
}
}
let trim_dur = (end - start).max(0.0);
let mut expected = if media.duration > 0.0 { trim_dur } else { 0.0 };
for op in ops {
match op.kind {
OpKind::Trim => {
if start > 0.0 {
pre.push("-ss".into());
pre.push(fmt_arg_secs(start));
}
if explicit_end {
out.push("-t".into());
out.push(fmt_arg_secs(trim_dur));
}
if op.choice(2) == 1 {
copy_requested = true;
}
}
OpKind::Resize => {
has_resize_op = true;
let f = match op.choice(0) {
0 => "scale=-2:1080".into(),
1 => "scale=-2:720".into(),
2 => "scale=-2:480".into(),
3 => "scale=-2:360".into(),
4 => "scale=-2:1440".into(),
5 => "scale=-2:2160".into(),
6 => "scale='trunc(iw/4)*2':-2".into(),
_ => {
let w = if op.text(1).is_empty() { "-2" } else { op.text(1) };
let h = if op.text(2).is_empty() { "-2" } else { op.text(2) };
if w == "-2" && h == "-2" {
notes.push("Resize: no custom size given, skipped".into());
continue;
}
format!("scale={}:{}", w, h)
}
};
vf.push(f);
}
OpKind::Crop => {
let f = match op.choice(0) {
0 => "crop='min(iw,ih)':'min(iw,ih)'".into(),
1 => "crop='min(iw,ih*16/9)':'min(ih,iw*9/16)'".into(),
2 => "crop='min(iw,ih*9/16)':'min(ih,iw*16/9)'".into(),
3 => "crop='min(iw,ih*4/3)':'min(ih,iw*3/4)'".into(),
_ => {
if op.text(1).is_empty() || op.text(2).is_empty() {
notes.push("Crop: custom width/height missing, skipped".into());
continue;
}
let mut f = format!("crop={}:{}", op.text(1), op.text(2));
if !op.text(3).is_empty() && !op.text(4).is_empty() {
f = format!("{}:{}:{}", f, op.text(3), op.text(4));
}
f
}
};
vf.push(f);
}
OpKind::Rotate => {
vf.push(
match op.choice(0) {
0 => "transpose=1",
1 => "transpose=2",
2 => "hflip,vflip",
3 => "hflip",
_ => "vflip",
}
.into(),
);
}
OpKind::Speed => {
let f: f64 = op.choice_str(0).trim_end_matches('x').parse().unwrap_or(1.0);
speed_factor = f;
if has_video {
vf.push(format!("setpts=PTS/{}", f));
}
if has_audio {
af.extend(atempo_chain(f));
}
}
OpKind::Adjust => {
let b = op.choice_str(0).trim_start_matches('+');
let c = op.choice_str(1);
let s = op.choice_str(2);
if b == "0" && c == "1.0" && s == "1.0" {
notes.push("Color adjust: everything at default, skipped".into());
} else {
vf.push(format!("eq=brightness={}:contrast={}:saturation={}", b, c, s));
}
}
OpKind::Effect => {
let fade_out_start = (trim_dur - 1.0).max(0.0);
match op.choice(0) {
0 => vf.push("hue=s=0".into()),
1 => vf.push(
"colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131"
.into(),
),
2 => vf.push("gblur=sigma=8".into()),
3 => vf.push("unsharp=5:5:1.0".into()),
4 => vf.push("vignette".into()),
5 => vf.push("hqdn3d=4".into()),
6 => vf.push("fade=t=in:st=0:d=1".into()),
7 => vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start)),
_ => {
vf.push("fade=t=in:st=0:d=1".into());
vf.push(format!("fade=t=out:st={:.2}:d=1", fade_out_start));
}
}
}
OpKind::Fps => {
has_fps_op = true;
vf.push(format!("fps={}", op.choice_str(0)));
}
OpKind::Compress => {
let h265 = op.choice(0) == 1;
let crf = match (h265, op.choice(1)) {
(false, 0) => 23,
(false, 1) => 18,
(false, 2) => 28,
(false, _) => 33,
(true, 0) => 27,
(true, 1) => 22,
(true, 2) => 32,
(true, _) => 36,
};
let preset = match op.choice(2) {
0 => "medium",
1 => "fast",
_ => "slow",
};
out.push("-c:v".into());
out.push(if h265 { "libx265" } else { "libx264" }.into());
out.push("-crf".into());
out.push(crf.to_string());
out.push("-preset".into());
out.push(preset.into());
out.push("-pix_fmt".into());
out.push("yuv420p".into());
if has_audio {
out.push("-c:a".into());
out.push("aac".into());
out.push("-b:a".into());
out.push("128k".into());
}
if h265 && (ext == "mp4" || ext == "mov") {
out.push("-tag:v".into());
out.push("hvc1".into());
notes.push("H.265 tagged as hvc1 so Apple players accept it".into());
}
}
// Format and Audio offer different choices for video and audio
// inputs, so they are matched by name rather than index.
OpKind::Format => {
let sel = op.choice_str(0);
if sel.starts_with("MP4") {
ext = "mp4".into();
} else if sel.starts_with("MKV") {
ext = "mkv".into();
} else if sel.starts_with("WebM") {
ext = "webm".into();
out.push("-c:v".into());
out.push("libvpx-vp9".into());
out.push("-crf".into());
out.push("32".into());
out.push("-b:v".into());
out.push("0".into());
if has_audio {
out.push("-c:a".into());
out.push("libopus".into());
}
} else if sel.starts_with("MOV") {
ext = "mov".into();
} else if sel.starts_with("GIF") {
ext = "gif".into();
gif = true;
} else {
audio_only_output = true;
out.push("-vn".into());
if sel.starts_with("MP3") {
ext = "mp3".into();
out.push("-c:a".into());
out.push("libmp3lame".into());
out.push("-q:a".into());
out.push("2".into());
} else if sel.starts_with("M4A") {
ext = "m4a".into();
out.push("-c:a".into());
out.push("aac".into());
out.push("-b:a".into());
out.push("192k".into());
} else if sel.starts_with("FLAC") {
ext = "flac".into();
out.push("-c:a".into());
out.push("flac".into());
} else if sel.starts_with("OGG") {
ext = "ogg".into();
out.push("-c:a".into());
out.push("libopus".into());
out.push("-b:a".into());
out.push("128k".into());
} else {
ext = "wav".into();
}
}
}
OpKind::Audio => {
if !has_audio {
notes.push("Audio: this file has no audio track, skipped".into());
continue;
}
match op.choice_str(0) {
"Remove audio" => {
out.push("-an".into());
}
"Volume +50%" => af.push("volume=1.5".into()),
"Volume 2x" => af.push("volume=2.0".into()),
"Volume -50%" => af.push("volume=0.5".into()),
"Normalize loudness" => af.push("loudnorm".into()),
_ => {
af.push("afade=t=in:st=0:d=1".into());
af.push(format!(
"afade=t=out:st={:.2}:d=1",
(trim_dur - 1.0).max(0.0)
));
}
}
}
}
}
if !has_video && !vf.is_empty() {
notes.push("This file has no video stream; picture edits were skipped".into());
vf.clear();
}
if audio_only_output && !vf.is_empty() {
notes.push("Audio-only output: picture edits were dropped".into());
vf.clear();
}
// Stream copy is only possible when nothing has to be re-encoded.
let only_trims = ops.iter().all(|o| o.kind == OpKind::Trim);
if copy_requested {
if only_trims {
out.push("-c".into());
out.push("copy".into());
notes.push("Stream copy: instant, but cuts land on keyframes (\u{b1}a few seconds)".into());
} else {
notes.push("Stream copy disabled: other edits require re-encoding".into());
}
}
if speed_factor != 1.0 && expected > 0.0 {
expected /= speed_factor;
}
// Assemble the argument list.
let mut args: Vec<String> = vec!["-y".into()];
args.extend(pre);
args.push("-i".into());
args.push(input.to_string_lossy().into_owned());
if gif {
// GIF needs a palette pass to avoid ugly dithering; bundle the user's
// filters into the palette graph.
let mut chain: Vec<String> = Vec::new();
if !has_fps_op {
chain.push("fps=12".into());
}
if !has_resize_op {
chain.push("scale=480:-2:flags=lanczos".into());
}
chain.extend(vf);
let fc = format!(
"{},split[a][b];[a]palettegen[p];[b][p]paletteuse",
chain.join(",")
);
args.push("-filter_complex".into());
args.push(fc);
args.push("-an".into());
af.clear();
notes.push("GIF: palette pass added for good colors; audio removed".into());
} else {
if !vf.is_empty() {
args.push("-vf".into());
args.push(vf.join(","));
}
if !af.is_empty() {
args.push("-af".into());
args.push(af.join(","));
}
}
args.extend(out);
let dir = input.parent().unwrap_or(Path::new("."));
let stem = if output_stem.trim().is_empty() { "output" } else { output_stem.trim() };
let output = dir.join(format!("{}.{}", stem, ext));
args.push(output.to_string_lossy().into_owned());
if output.exists() {
notes.push("Output file already exists and will be overwritten (-y)".into());
}
Built { args, output, expected_duration: expected, notes }
}
/// atempo only accepts 0.52.0 per instance, so chain instances for larger
/// factors.
fn atempo_chain(mut f: f64) -> Vec<String> {
let mut v = Vec::new();
while f > 2.0 {
v.push("atempo=2.0".to_string());
f /= 2.0;
}
while f < 0.5 {
v.push("atempo=0.5".to_string());
f /= 0.5;
}
if (f - 1.0).abs() > 1e-3 || v.is_empty() {
v.push(format!("atempo={}", f));
}
v
}
/// Parse "1:23:45.5", "12:34" or "90.5" into seconds.
pub fn parse_time(s: &str) -> Option<f64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let mut total = 0.0;
for part in s.split(':') {
let n: f64 = part.parse().ok()?;
total = total * 60.0 + n;
}
Some(total)
}
fn fmt_arg_secs(s: f64) -> String {
let r = format!("{:.3}", s);
r.trim_end_matches('0').trim_end_matches('.').to_string()
}
pub fn fmt_clock(secs: f64) -> String {
let s = secs.max(0.0) as u64;
let (h, m, sec) = (s / 3600, (s % 3600) / 60, s % 60);
if h > 0 {
format!("{}:{:02}:{:02}", h, m, sec)
} else {
format!("{}:{:02}", m, sec)
}
}
pub fn fmt_size(bytes: u64) -> String {
let b = bytes as f64;
if b >= 1e9 {
format!("{:.2} GB", b / 1e9)
} else if b >= 1e6 {
format!("{:.1} MB", b / 1e6)
} else if b >= 1e3 {
format!("{:.0} KB", b / 1e3)
} else {
format!("{} B", bytes)
}
}
/// Render the command for display, shell-quoting where needed so it can be
/// copy-pasted into a terminal.
pub fn preview_string(args: &[String]) -> String {
let mut s = String::from("ffmpeg");
for a in args {
s.push(' ');
if a.chars().any(|c| !(c.is_alphanumeric() || "-_./=:".contains(c))) {
s.push('\'');
s.push_str(&a.replace('\'', "'\\''"));
s.push('\'');
} else {
s.push_str(a);
}
}
s
}
// ---------------------------------------------------------------------------
// Running
pub enum RunMsg {
Progress { secs: f64, speed: String },
Stderr(String),
Finished,
}
pub struct Runner {
pub child: Child,
pub rx: Receiver<RunMsg>,
}
pub fn run(args: &[String]) -> Result<Runner> {
let mut child = Command::new("ffmpeg")
.args(["-hide_banner", "-nostdin", "-loglevel", "error", "-progress", "pipe:1"])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to start ffmpeg")?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || {
let mut secs = 0.0_f64;
let mut speed = String::new();
for line in BufReader::new(stdout).lines().map_while(|l| l.ok()) {
if let Some((k, v)) = line.split_once('=') {
match k {
// out_time_us and out_time_ms are both microseconds
// (a long-standing ffmpeg quirk).
"out_time_us" | "out_time_ms" => {
if let Ok(us) = v.trim().parse::<i64>() {
secs = us.max(0) as f64 / 1e6;
}
}
"speed" => speed = v.trim().to_string(),
"progress" => {
let _ = tx2.send(RunMsg::Progress { secs, speed: speed.clone() });
}
_ => {}
}
}
}
let _ = tx2.send(RunMsg::Finished);
});
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(|l| l.ok()) {
if !line.trim().is_empty() {
let _ = tx.send(RunMsg::Stderr(line));
}
}
});
Ok(Runner { child, rx })
}
+58
View File
@@ -0,0 +1,58 @@
mod app;
mod ffmpeg;
mod ops;
mod ui;
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Result};
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use app::App;
fn main() -> Result<()> {
if let Some(arg) = std::env::args().nth(1) {
match arg.as_str() {
"--version" | "-V" => {
println!("lazyff {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => {
println!("lazyff {} — a friendly TUI for FFmpeg", env!("CARGO_PKG_VERSION"));
println!("Usage: lazyff (opens a file browser in the current directory)");
return Ok(());
}
}
}
for bin in ["ffmpeg", "ffprobe"] {
if Command::new(bin).arg("-version").output().is_err() {
bail!("{bin} was not found on your PATH. Please install FFmpeg first.");
}
}
let mut terminal = ratatui::init();
let result = run(&mut terminal);
ratatui::restore();
result
}
fn run(terminal: &mut DefaultTerminal) -> Result<()> {
let mut app = App::new()?;
loop {
app.poll_run_messages();
terminal.draw(|f| ui::draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
app.on_key(key);
}
}
}
if app.should_quit {
return Ok(());
}
}
}
+309
View File
@@ -0,0 +1,309 @@
//! The catalogue of editing operations and their user-editable fields.
//!
//! Every operation is a uniform list of fields (either a multiple-choice
//! value or a free-text value) so the form UI and the command builder can
//! treat them generically.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OpKind {
Trim,
Resize,
Crop,
Rotate,
Speed,
Adjust,
Effect,
Fps,
Compress,
Format,
Audio,
}
use OpKind::*;
impl OpKind {
pub const ALL: [OpKind; 11] = [
Trim, Resize, Crop, Rotate, Speed, Adjust, Effect, Fps, Compress, Format, Audio,
];
pub fn name(self) -> &'static str {
match self {
Trim => "Trim / Cut",
Resize => "Resize",
Crop => "Crop",
Rotate => "Rotate / Flip",
Speed => "Speed",
Adjust => "Color adjust",
Effect => "Visual effect",
Fps => "Frame rate",
Compress => "Compress",
Format => "Convert format",
Audio => "Audio",
}
}
/// Edits that only make sense when the input has a picture; hidden for
/// audio files.
pub fn video_only(self) -> bool {
matches!(
self,
Resize | Crop | Rotate | Adjust | Effect | Fps | Compress
)
}
pub fn blurb(self) -> &'static str {
match self {
Trim => "Keep only part of the file (start / end time)",
Resize => "Make the picture smaller or larger",
Crop => "Cut away the edges of the picture",
Rotate => "Rotate by 90/180 degrees or mirror it",
Speed => "Play faster or slower (pitch is preserved)",
Adjust => "Brightness, contrast and saturation",
Effect => "Grayscale, blur, sharpen, fade in/out...",
Fps => "Change frames per second",
Compress => "Shrink the file size by re-encoding",
Format => "Save as a different file type",
Audio => "Volume, loudness, fades, remove the track",
}
}
/// Default fields. Some forms offer different choices depending on
/// whether the input has a video stream.
pub fn build(self, has_video: bool) -> Operation {
let fields = match self {
Trim => vec![
Field::text("Start (h:mm:ss)", "0:00"),
Field::text("End (h:mm:ss, empty = end)", ""),
Field::choice(
"Mode",
&["Re-encode (precise)", "Stream copy (instant, trim only)"],
0,
),
],
Resize => vec![
Field::choice(
"Size",
&[
"1080p", "720p", "480p", "360p", "1440p", "4K (2160p)", "Half size",
"Custom",
],
0,
),
Field::text("Custom width (if Custom)", ""),
Field::text("Custom height (if Custom)", ""),
],
Crop => vec![
Field::choice(
"Shape (centered)",
&[
"Square (1:1)",
"Widescreen (16:9)",
"Vertical (9:16)",
"Classic (4:3)",
"Custom",
],
0,
),
Field::text("Custom width (if Custom)", ""),
Field::text("Custom height (if Custom)", ""),
Field::text("Custom X (empty = center)", ""),
Field::text("Custom Y (empty = center)", ""),
],
Rotate => vec![Field::choice(
"Rotation",
&[
"90\u{b0} clockwise",
"90\u{b0} counter-clockwise",
"180\u{b0}",
"Mirror horizontally",
"Mirror vertically",
],
0,
)],
Speed => vec![Field::choice(
"Speed",
&["2x", "1.5x", "1.25x", "0.75x", "0.5x", "0.25x", "3x", "4x"],
0,
)],
Adjust => vec![
Field::choice(
"Brightness",
&["-0.3", "-0.2", "-0.1", "0", "+0.1", "+0.2", "+0.3"],
3,
),
Field::choice("Contrast", &["0.8", "0.9", "1.0", "1.1", "1.2", "1.4"], 2),
Field::choice(
"Saturation",
&["0.0", "0.5", "0.8", "1.0", "1.2", "1.5", "2.0"],
3,
),
],
Effect => vec![Field::choice(
"Effect",
&[
"Grayscale",
"Sepia",
"Blur",
"Sharpen",
"Vignette",
"Denoise",
"Fade in",
"Fade out",
"Fade in + out",
],
0,
)],
Fps => vec![Field::choice("Frame rate", &["30", "24", "60", "15", "12"], 0)],
Compress => vec![
Field::choice(
"Codec",
&["H.264 (most compatible)", "H.265 (smaller files)"],
0,
),
Field::choice(
"Quality",
&["Balanced", "High (bigger file)", "Small", "Tiny (worst quality)"],
0,
),
Field::choice(
"Encoding speed",
&["Medium", "Fast (bigger file)", "Slow (smaller file)"],
0,
),
],
Format => {
let options: &'static [&'static str] = if has_video {
&[
"MP4 video",
"MKV video",
"WebM video",
"MOV video",
"GIF animation",
"MP3 (audio only)",
"M4A (audio only)",
"WAV (audio only)",
]
} else {
&["MP3", "M4A (AAC)", "FLAC (lossless)", "WAV (lossless)", "OGG (Opus)"]
};
vec![Field::choice("Convert to", options, 0)]
}
Audio => {
let options: &'static [&'static str] = if has_video {
&[
"Remove audio",
"Volume +50%",
"Volume 2x",
"Volume -50%",
"Normalize loudness",
"Fade in + out",
]
} else {
&[
"Volume +50%",
"Volume 2x",
"Volume -50%",
"Normalize loudness",
"Fade in + out",
]
};
vec![Field::choice("Audio", options, 0)]
}
};
Operation { kind: self, fields }
}
}
#[derive(Clone)]
pub enum FieldValue {
Choice {
options: &'static [&'static str],
selected: usize,
},
Text(String),
}
#[derive(Clone)]
pub struct Field {
pub label: &'static str,
pub value: FieldValue,
}
impl Field {
fn choice(label: &'static str, options: &'static [&'static str], selected: usize) -> Self {
Field { label, value: FieldValue::Choice { options, selected } }
}
fn text(label: &'static str, initial: &str) -> Self {
Field { label, value: FieldValue::Text(initial.to_string()) }
}
}
#[derive(Clone)]
pub struct Operation {
pub kind: OpKind,
pub fields: Vec<Field>,
}
impl Operation {
/// Selected index of a choice field.
pub fn choice(&self, i: usize) -> usize {
match &self.fields[i].value {
FieldValue::Choice { selected, .. } => *selected,
FieldValue::Text(_) => 0,
}
}
/// Selected option string of a choice field.
pub fn choice_str(&self, i: usize) -> &'static str {
match &self.fields[i].value {
FieldValue::Choice { options, selected } => options[*selected],
FieldValue::Text(_) => "",
}
}
/// Trimmed contents of a text field.
pub fn text(&self, i: usize) -> &str {
match &self.fields[i].value {
FieldValue::Text(s) => s.trim(),
FieldValue::Choice { .. } => "",
}
}
/// One-line description shown in the operations list.
pub fn summary(&self) -> String {
match self.kind {
Trim => {
let start = if self.text(0).is_empty() { "0:00" } else { self.text(0) };
let end = if self.text(1).is_empty() { "end" } else { self.text(1) };
let mode = if self.choice(2) == 1 { ", stream copy" } else { "" };
format!("{} \u{2192} {}{}", start, end, mode)
}
Resize => {
if self.choice_str(0) == "Custom" {
format!("{}\u{d7}{}", self.text(1), self.text(2))
} else {
self.choice_str(0).to_string()
}
}
Crop => {
if self.choice_str(0) == "Custom" {
format!("{}\u{d7}{}", self.text(1), self.text(2))
} else {
self.choice_str(0).to_string()
}
}
Adjust => format!(
"bright {} / contrast {} / sat {}",
self.choice_str(0),
self.choice_str(1),
self.choice_str(2)
),
Compress => format!(
"{}, {}",
self.choice_str(0).split(' ').next().unwrap_or(""),
self.choice_str(1)
),
_ => self.choice_str(0).to_string(),
}
}
}
+430
View File
@@ -0,0 +1,430 @@
//! All rendering. The app is two screens (file browser, editor) plus
//! centered modal popups.
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::Frame;
use crate::app::{App, Modal, Screen};
use crate::ffmpeg;
use crate::ops::FieldValue;
// Catppuccin Macchiato (https://catppuccin.com), blue accent.
const ACCENT: Color = Color::Rgb(0x8a, 0xad, 0xf4); // blue
const ON_ACCENT: Color = Color::Rgb(0x24, 0x27, 0x3a); // base, for text on accent
const TEXT: Color = Color::Rgb(0xca, 0xd3, 0xf5); // text
const SUBTLE: Color = Color::Rgb(0xa5, 0xad, 0xcb); // subtext0
const DIM: Color = Color::Rgb(0x6e, 0x73, 0x8d); // overlay0
const YELLOW: Color = Color::Rgb(0xee, 0xd4, 0x9f);
const GREEN: Color = Color::Rgb(0xa6, 0xda, 0x95);
const RED: Color = Color::Rgb(0xed, 0x87, 0x96);
const LAVENDER: Color = Color::Rgb(0xb7, 0xbd, 0xf8);
pub fn draw(f: &mut Frame, app: &mut App) {
match app.screen {
Screen::Browser => draw_browser(f, app),
Screen::Editor => draw_editor(f, app),
}
// Modals render on top of whichever screen is active.
let modal_ptr = app.modal.take();
if let Some(modal) = &modal_ptr {
draw_modal(f, app, modal);
}
app.modal = modal_ptr;
}
fn title_block(title: impl Into<String>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(Span::styled(
format!(" {} ", title.into()),
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
}
fn key_hint(line: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (key, what) in line {
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(ON_ACCENT).bg(ACCENT),
));
spans.push(Span::styled(format!(" {} ", what), Style::default().fg(SUBTLE)));
}
Line::from(spans)
}
// ---------------------------------------------------------------------------
// Browser
fn draw_browser(f: &mut Frame, app: &mut App) {
let [header, main, footer] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
.areas(f.area());
let banner = Paragraph::new(Line::from(vec![
Span::styled("lazyff ", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
Span::styled("— pick a video or audio file to edit", Style::default().fg(SUBTLE)),
]))
.block(title_block(app.cwd.display().to_string()));
f.render_widget(banner, header);
let items: Vec<ListItem> = app
.entries
.iter()
.map(|e| {
if e.is_dir {
ListItem::new(Line::from(vec![
Span::styled("\u{1f4c1} ", Style::default()),
Span::styled(e.name.clone(), Style::default().fg(LAVENDER)),
Span::raw("/"),
]))
} else {
ListItem::new(Line::from(vec![
Span::raw("\u{1f3ac} "),
Span::raw(e.name.clone()),
]))
}
})
.collect();
let mut block = title_block("Files");
if let Some(err) = &app.browser_error {
block = block.title_bottom(Line::from(Span::styled(
format!(" {} ", err.lines().next().unwrap_or("error")),
Style::default().fg(RED),
)));
}
let list = List::new(items)
.block(block)
.highlight_style(Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD))
.highlight_symbol(" ");
f.render_stateful_widget(list, main, &mut app.browser_state);
let hints = key_hint(&[
("\u{2191}\u{2193}", "move"),
("Enter", "open"),
("Backspace", "up a folder"),
("q", "quit"),
]);
f.render_widget(Paragraph::new(hints), footer);
}
// ---------------------------------------------------------------------------
// Editor
fn draw_editor(f: &mut Frame, app: &mut App) {
let [main, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(f.area());
let [left, right] =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).areas(main);
// Left: list of queued operations.
let input_name = app
.input
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let items: Vec<ListItem> = if app.ops.is_empty() {
vec![ListItem::new(Text::from(vec![
Line::raw(""),
Line::styled(" No edits yet.", Style::default().fg(DIM)),
Line::styled(" Press 'a' to add one (trim, resize, compress...).", Style::default().fg(DIM)),
]))]
} else {
app.ops
.iter()
.enumerate()
.map(|(i, op)| {
ListItem::new(Line::from(vec![
Span::styled(format!(" {}. ", i + 1), Style::default().fg(DIM)),
Span::styled(
format!("{:<15} ", op.kind.name()),
Style::default().fg(YELLOW),
),
Span::raw(op.summary()),
]))
})
.collect()
};
let list = List::new(items)
.block(title_block(format!("Edits \u{2014} {}", input_name)))
.highlight_style(Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD));
f.render_stateful_widget(list, left, &mut app.op_state);
// Right: media info on top, command preview below.
let [info_area, cmd_area] =
Layout::vertical([Constraint::Length(9), Constraint::Min(0)]).areas(right);
if let Some(m) = &app.media {
let mut lines = vec![
info_line("Container", &m.format),
info_line("Duration", &ffmpeg::fmt_clock(m.duration)),
info_line("Size", &ffmpeg::fmt_size(m.size_bytes)),
];
if let (Some(c), Some(w), Some(h)) = (&m.video_codec, m.width, m.height) {
let fps = m.fps.map(|f| format!(" @ {:.2} fps", f)).unwrap_or_default();
lines.push(info_line("Video", &format!("{} {}\u{d7}{}{}", c, w, h, fps)));
} else {
lines.push(info_line("Video", "none (audio file)"));
}
lines.push(info_line(
"Audio",
m.audio_codec.as_deref().unwrap_or("none"),
));
f.render_widget(
Paragraph::new(lines).block(title_block("Media info")),
info_area,
);
}
// Command preview, rebuilt every frame (cheap) so it always matches.
if let (Some(input), Some(media)) = (&app.input, &app.media) {
let built = ffmpeg::build(input, &app.ops, &app.output_stem, media);
let mut text = Text::default();
text.push_line(Line::styled(
format!("Output: {}", built.output.display()),
Style::default().fg(GREEN),
));
text.push_line(Line::raw(""));
text.push_line(Line::styled(
ffmpeg::preview_string(&built.args),
Style::default().fg(TEXT),
));
if !built.notes.is_empty() {
text.push_line(Line::raw(""));
for n in &built.notes {
text.push_line(Line::styled(
format!("\u{2139} {}", n),
Style::default().fg(YELLOW),
));
}
}
f.render_widget(
Paragraph::new(text)
.wrap(Wrap { trim: false })
.block(title_block("Command (what lazyff will run)")),
cmd_area,
);
}
let hints = key_hint(&[
("a", "add edit"),
("Enter", "change"),
("d", "delete"),
("J/K", "reorder"),
("o", "output name"),
("r", "run!"),
("Esc", "files"),
("q", "quit"),
]);
f.render_widget(Paragraph::new(hints), footer);
}
fn info_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<10}", label), Style::default().fg(DIM)),
Span::raw(value.to_string()),
])
}
// ---------------------------------------------------------------------------
// Modals
fn draw_modal(f: &mut Frame, app: &mut App, modal: &Modal) {
match modal {
Modal::AddOp { selected } => {
let kinds = app.available_kinds();
let title = if app.has_video() {
"Add an edit (Enter to choose, Esc to cancel)"
} else {
"Add an edit \u{2014} audio file (Enter to choose, Esc to cancel)"
};
let area = centered(f.area(), 64, kinds.len() as u16 + 2);
f.render_widget(Clear, area);
let items: Vec<ListItem> = kinds
.iter()
.map(|k| {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<15} ", k.name()),
Style::default().fg(YELLOW),
),
Span::styled(k.blurb(), Style::default().fg(SUBTLE)),
]))
})
.collect();
let mut state = ListState::default();
state.select(Some(*selected));
let list = List::new(items)
.block(title_block(title))
.highlight_style(
Style::default().bg(ACCENT).fg(ON_ACCENT).add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut state);
}
Modal::EditOp { field, op, .. } => {
let area = centered(f.area(), 66, op.fields.len() as u16 + 4);
f.render_widget(Clear, area);
let mut lines = vec![Line::raw("")];
for (i, fld) in op.fields.iter().enumerate() {
let selected = i == *field;
let marker = if selected { " \u{25b8} " } else { " " };
let label_style = if selected {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(SUBTLE)
};
let value = match &fld.value {
FieldValue::Choice { options, selected: s } => {
format!("\u{25c2} {} \u{25b8}", options[*s])
}
FieldValue::Text(s) => {
if selected {
format!("{}\u{2581}", s)
} else if s.is_empty() {
"(empty)".to_string()
} else {
s.clone()
}
}
};
lines.push(Line::from(vec![
Span::raw(marker.to_string()),
Span::styled(format!("{:<28}", fld.label), label_style),
Span::styled(
value,
if selected {
Style::default().fg(TEXT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(SUBTLE)
},
),
]));
}
lines.push(Line::raw(""));
lines.push(Line::styled(
" \u{2191}\u{2193} field \u{2502} \u{2190}\u{2192} change \u{2502} type to edit \u{2502} Enter save \u{2502} Esc cancel",
Style::default().fg(DIM),
));
f.render_widget(
Paragraph::new(lines).block(title_block(op.kind.name())),
area,
);
}
Modal::Output { text } => {
let area = centered(f.area(), 60, 5);
f.render_widget(Clear, area);
let lines = vec![
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}\u{2581}", text),
Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
),
Span::styled(
" (extension is chosen automatically)",
Style::default().fg(DIM),
),
]),
Line::styled(" Enter save \u{2502} Esc cancel", Style::default().fg(DIM)),
];
f.render_widget(
Paragraph::new(lines).block(title_block("Output file name")),
area,
);
}
Modal::Running => {
let area = centered(f.area(), 64, 8);
f.render_widget(Clear, area);
let block = title_block("Encoding\u{2026} (Esc to cancel)");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Clear, inner);
if let Some(rs) = &app.run {
let [gauge_area, info_area, err_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
let ratio = if rs.expected > 0.0 {
(rs.secs / rs.expected).clamp(0.0, 1.0)
} else {
0.0
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(ACCENT))
.ratio(ratio)
.label(format!("{:.0}%", ratio * 100.0));
f.render_widget(gauge, gauge_area.inner(ratatui::layout::Margin::new(1, 1)));
let speed = if rs.speed.is_empty() { "?".into() } else { rs.speed.clone() };
let info = Paragraph::new(format!(
"{} / {} speed {}",
ffmpeg::fmt_clock(rs.secs),
ffmpeg::fmt_clock(rs.expected),
speed
))
.alignment(Alignment::Center);
f.render_widget(info, info_area);
if !rs.errors.is_empty() {
let last = rs.errors.last().cloned().unwrap_or_default();
f.render_widget(
Paragraph::new(Line::styled(last, Style::default().fg(RED)))
.wrap(Wrap { trim: true }),
err_area,
);
}
}
}
Modal::Done { success, title, message } => {
let wanted = message.lines().count() as u16 + 4;
let area = centered(f.area(), 70, wanted.min(f.area().height.saturating_sub(4)));
f.render_widget(Clear, area);
let color = if *success { GREEN } else { RED };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
.title(Span::styled(
format!(" {} ", title),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(Span::styled(
" Enter to close ",
Style::default().fg(DIM),
)));
f.render_widget(
Paragraph::new(message.clone()).wrap(Wrap { trim: false }).block(block),
area,
);
}
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}