From 471aa6fb0c7f60b906ffae8763ae3526260183ba Mon Sep 17 00:00:00 2001 From: Weetile Date: Thu, 11 Jun 2026 11:23:01 +0100 Subject: [PATCH] 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. --- .gitignore | 1 + Cargo.lock | 655 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 ++ README.md | 62 +++++ src/app.rs | 487 +++++++++++++++++++++++++++++++++++++ src/ffmpeg.rs | 590 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 58 +++++ src/ops.rs | 309 ++++++++++++++++++++++++ src/ui.rs | 430 +++++++++++++++++++++++++++++++++ 9 files changed, 2606 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/ffmpeg.rs create mode 100644 src/main.rs create mode 100644 src/ops.rs create mode 100644 src/ui.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6b9adde --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4802e52 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fff38e8 --- /dev/null +++ b/README.md @@ -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 +`_lazyff.` (rename with `o`); lazyff refuses to overwrite the +input file. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e7eb9ef --- /dev/null +++ b/src/app.rs @@ -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, + pub output: PathBuf, + pub canceled: bool, +} + +pub struct App { + pub should_quit: bool, + pub screen: Screen, + pub modal: Option, + + // file browser + pub cwd: PathBuf, + pub entries: Vec, + pub browser_state: ListState, + pub browser_error: Option, + + // editor + pub input: Option, + pub media: Option, + pub ops: Vec, + pub op_state: ListState, + pub output_stem: String, + + pub run: Option, +} + +impl App { + pub fn new() -> Result { + 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 { + 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 = 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 { + 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)); +} diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs new file mode 100644 index 0000000..89f9f68 --- /dev/null +++ b/src/ffmpeg.rs @@ -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, + pub height: Option, + pub fps: Option, + pub video_codec: Option, + pub audio_codec: Option, +} + +pub fn probe(path: &Path) -> Result { + 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::().ok()).unwrap_or(0.0); + let size_bytes = fmt["size"].as_str().and_then(|s| s.parse::().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 { + 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, + 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, +} + +pub fn build(input: &Path, ops: &[Operation], output_stem: &str, media: &MediaInfo) -> Built { + let mut pre: Vec = Vec::new(); // before -i + let mut vf: Vec = Vec::new(); // video filter chain + let mut af: Vec = Vec::new(); // audio filter chain + let mut out: Vec = Vec::new(); // output options + let mut notes: Vec = 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 = 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 = 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.5–2.0 per instance, so chain instances for larger +/// factors. +fn atempo_chain(mut f: f64) -> Vec { + 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 { + 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, +} + +pub fn run(args: &[String]) -> Result { + 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::() { + 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 }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7735617 --- /dev/null +++ b/src/main.rs @@ -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(()); + } + } +} diff --git a/src/ops.rs b/src/ops.rs new file mode 100644 index 0000000..35bd829 --- /dev/null +++ b/src/ops.rs @@ -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, +} + +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(), + } + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..62722a6 --- /dev/null +++ b/src/ui.rs @@ -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) -> 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 = 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 = 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 = 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, + } +}