diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 0000000..8e3482c --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,43 @@ +name: Python Code Quality Check (Ruff) + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff Check (Linting) + run: ruff check . + # if there are any issues it will exit with non-zero code. + + - name: Run Ruff Format Check + run: ruff format --check + # if there are any issues it will exit with non-zero code. + + - name: Show Unformatted Files For Debugging + if: failure() + run: | + echo "::error::Python code hasn't been formatted or has linting issues." + echo "Please run 'ruff check . --fix' and 'ruff format .' locally." + echo "Files with linting issues:" + ruff check . --statistics + echo "Files with formatting issues(diff):" + ruff format --check --diff . + # or --output-format=text to list them all \ No newline at end of file diff --git a/.gitignore b/.gitignore index b9bd150..6c3220b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .wakatime-project .ruff_cache/ +.flatpak-builder/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -164,3 +165,12 @@ cython_debug/ /nix/result /result + +# --- Snapcraft --- +prime/ +parts/ +stage/ +*.snap +*.manifest +*.assert +*.summary diff --git a/README.md b/README.md index 7b557b1..2f6db57 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,121 @@ -![anifetch](anifetch.webp) +![anifetch](docs/anifetch.webp) # Anifetch - Neofetch but animated. This is a small tool built with neofetch/fastfetch, ffmpeg and chafa. It allows you to use neofetch or fastfetch while having animations. -## How to Install -You need `bc` to be installed. For debian/ubuntu it's `apt install bc`. For Arch it's `pacman -S bc`. +## πŸ› οΈ How to Install -You need `chafa` to be installed. For debian/ubuntu it is `apt install chafa`. [Download Instructions](https://hpjansson.org/chafa/download/) +### Prerequisites -if you don't have ffmpeg, download it here: [ffmpeg download](https://www.ffmpeg.org/download.html) +Recommended Python version: 3.12 and later -Clone the git repo. +You need the following tools installed on your system: -```cmd -git clone https://github.com/Notenlish/anifetch +- `bc` + + - Debian/Ubuntu: `sudo apt install bc` + - Arch: `sudo pacman -S bc` + +- `chafa` + + - Debian/Ubuntu: `sudo apt install chafa` + - [Other distros – Download Instructions](https://hpjansson.org/chafa/download/) + +- `ffmpeg` (for video/audio playback) + + - Debian/Ubuntu: `sudo apt install ffmpeg` + - [Other systems – Download](https://www.ffmpeg.org/download.html) + +⚠️ Make sure `ffplay` is included in your `ffmpeg` installation (used for optional sound playback). + +--- + +### βœ… Recommended Installation (for regular users): via `pipx` + +```bash +pipx install git+https://github.com/Notenlish/anifetch.git ``` -You don't need to setup an venv or install any python modules. +This installs `anifetch` in an isolated environment, keeping your system Python clean. +You can then run the `anifetch` command **directly in your terminal**. + +βœ… **Benefits**: -Sound functionality is added via `ffplay`. If you install ffmpeg via a package manager like `apt` it should also install it automatically. +- Avoids dependency conflicts. +- Doesn’t pollute your global Python environment. +- Behaves like a native CLI tool. +- Easy updates with `pipx upgrade anifetch`. -## How to Use It +πŸ”§ Make sure `pipx` is installed: -Your neofetch logo file should only include a single character for the ascii art. Anifetch will attempt to find it and replace it with the chafa animation output. An example logo file can be found in `example-logo.txt`. Fastfetch doesnt need any special configuration. +```bash +sudo apt install pipx +pipx ensurepath +``` -An example neofetch config can be found here: `example-config.conf` +--- -Simply place your video/gif file in the project folder. There's an already included test file called `video.mp4`, you can use that if you want. +### πŸ‘¨β€πŸ’» Developer Installation (for contributors): via `pip` in a virtual environment -Then run `python3 anifetch.py -f [filename] --framerate 10 --width 40 --height 20 -c "[add optional chafa arguments if you want]"`. +```bash +git clone https://github.com/Notenlish/anifetch.git +cd anifetch +python3 -m venv venv +source venv/bin/activate +pip install -e . +``` -Here's an example command: `python3 anifetch.py -f "video.mp4" -r 10 -W 40 -H 20 -c "--symbols wide --fg-only"` +This installs `anifetch` in **editable mode** within a local virtual environment for development. -Run `python3 anifetch.py --help` if you need help. +You can then run the program in two ways: -You can also play a sound via `-s [sound filename]`. +- As a CLI: `anifetch` +- Or as a module: `python3 -m anifetch` (useful for debugging or internal testing) -## Creating a Shortcut +⚠️ Avoid using `pip install` outside a virtual environment on systems like Ubuntu. +This is restricted by [PEP 668](https://peps.python.org/pep-0668/) to protect the system Python. -Just add an shell alias to your `~/.bashrc` file. Example: `alias anifetch='python3 [path-to-anifetch.py] -f [path-to-video-file]'` +## ▢️ How to Use It -## Benchmarks +You don't need to configure anything for `fastfetch` or `neofetch`. If they already work on your machine, `anifetch` will detect and integrate them automatically. + +Place your video or gif file anywhere (e.g. your project folder). +By default, the included test file `example.mp4` is used (you don't need to add any arguments in this case). + +### Example usage: + +```bash +anifetch video.mp4 -r 10 -W 40 -H 20 -c "--symbols wide --fg-only" +``` + +### Optional arguments: + +- `-f` / `--file`: path to the video file (the path can be added without the `-f` argument) +- `-s` / `--sound`: optional sound file to play alongside (requires `ffplay`) +- `-r` / `--framerate`: frame rate of playback +- `-W` / `--width`: video width +- `-H` / `--height`: video height (may be automatically fixed with the width) +- `-c` / `--chafa`: extra arguments to pass to `chafa` +- `-ff` / `--fast-fetch`: uses `fastfetch` instead of `neofetch` if available + +For full help: + +```bash +anifetch --help +``` + +## 🎯 Creating a Shortcut (if installed manually) + +If you did not use `pipx` and installed manually via `git` (for developpers), you can still create an alias in your shell config (`~/.bashrc`, `~/.zshrc`, etc.): + +```bash +alias anifetch='python3 /path/to/anifetch.py -f /path/to/video.mp4' +``` + +--- + +## πŸ“Š Benchmarks Here's the benchmark from running each cli 10 times. Tested on Linux Mint with Intel I5-12500H. @@ -56,7 +130,7 @@ Here's the benchmark from running each cli 10 times. Tested on Linux Mint with I As it can be seen, **Anifetch** is quite fast if you cache the animations, especially when paired with fastfetch. -## Notes +## πŸ“ Notes Anifetch attempts to cache the animation so that it doesn't need to render them again when you run it with the same file. However, if the name of the file is the same, but it's contents has changed, it won't re-render it. In that case, you will need to add `--force-render` as an argument to `anifetch.py` so that it re-renders it. You only have to do this only once when you change the file contents. @@ -68,7 +142,7 @@ If you set animation resolution really big it may not be able to keep up with th Currently only the `symbols` format of chafa is supported, formats like kitty, iterm etc. are not supported. If you try to tell chafa to use iterm, kitty etc. it will just override your format with `symbols` mode. -## What's Next +## 🚧 What's Next - [x] Add music support @@ -106,6 +180,10 @@ Currently only the `symbols` format of chafa is supported, formats like kitty, i - [ ] Add an option to generate an mp4 with the terminal rendered animation(for putting it as a desktop background) +## πŸ’» Dev commands + +Devs can use additional tools in the `tools` folder in order to test new features from Anifetch. + ## Credits Neofetch: [Neofetch](https://github.com/dylanaraps/neofetch) diff --git a/anifetch.py b/anifetch.py deleted file mode 100644 index b2cd925..0000000 --- a/anifetch.py +++ /dev/null @@ -1,434 +0,0 @@ -import argparse -import json -import os -import pathlib -import shutil -import subprocess -import sys -import time - - -def print_verbose(*msg): - if args.verbose: - print(*msg) - -def get_ext_from_codec(codec:str): - codec_extension_map = { - "aac": "m4a", - "mp3": "mp3", - "opus": "opus", - "vorbis": "ogg", - "pcm_s16le": "wav", - "flac": "flac", - "alac": "m4a" - } - return codec_extension_map.get(codec,"bin") - -def check_codec_of_file(file:str): - ffprobe_cmd = ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", file] - codec = str(subprocess.check_output(ffprobe_cmd, text=True).strip()) - return codec - -def extract_audio_from_file(file:str, extension): - audio_file = BASE_PATH / f"output_audio.{extension}" - extract_cmd = ["ffmpeg", "-i", file, "-y", "-vn", "-c:a", "copy", "-loglevel","quiet", audio_file] - subprocess.call(extract_cmd) - return audio_file - -def get_data_path(): - xdg_data_home = os.environ.get( - "XDG_DATA_HOME", os.path.expanduser("~/.local/share") - ) - data_path = os.path.join(xdg_data_home, "anifetch") - os.makedirs(data_path, exist_ok=True) - return pathlib.Path(data_path) - - -def check_sound_flag(): - if "--sound" in sys.argv or "-s" in sys.argv: - return True - return False - -def check_chroma_flag(): - if "--chroma" in sys.argv: - return True - return False - - - -st = time.time() - -GAP = 2 -PAD_LEFT = 4 - -parser = argparse.ArgumentParser( - prog="Anifetch", - description="Allows you to use neofetch with video in terminal(using chafa).", -) -parser.add_argument( - "-b", - "--benchmark", - default=False, - help="For testing. Runs Anifetch without actually starting the animation.", - action="store_true", -) -parser.add_argument( - "filename", - nargs="?", # <--filename> is optional - default=str(pathlib.Path.home() / "anifetch/example.mp4"), - help="Video file to use (default: ~/anifetch/example.mp4)", - type=str, -) -parser.add_argument( - "-w", "-W", "--width", default=40, help="Width of the chafa animation.", type=int -) -parser.add_argument( - "-H", "--height", default=20, help="Height of the chafa animation.", type=int -) -parser.add_argument("-v", "--verbose", default=False, action="store_true") -parser.add_argument( - "-r", - "--framerate", - default=10, - help="Sets the framerate when extracting frames from ffmpeg.", - type=int, -) -parser.add_argument( - "-pr", - "--playback-rate", - default=10, - help="Ignored when a sound is playing so that desync doesn't happen. Sets the playback rate of the animation. Not to be confused with the 'framerate' option. This basically sets for how long the script will wait before rendering new frame, while the framerate option affects how many frames are generated via ffmpeg.", -) -parser.add_argument( - "-s", - "--sound", - required=False, - nargs="?", - help="Optional. Will playback a sound file while displaying the animation. If you give only -s without any sound file it will attempt to extract the sound from the video.", - type=str, -) -parser.add_argument( - "-fr", - "--force-render", - default=False, - action="store_true", - help="Disabled by default. Anifetch saves the filename to check if the file has changed, if the name is same, it won't render it again. If enabled, the video will be forcefully rendered, whether it has the same name or not. Please note that it only checks for filename, if you changed the framerate then you'll need to force render.", -) -parser.add_argument( - "-c", - "--chafa-arguments", - default="--symbols ascii --fg-only", - help="Specify the arguments to give to chafa. For more informations, use 'chafa --help'", -) -parser.add_argument( - "-ff", - "--fast-fetch", - default=False, - help="Add this argument if you want to use fastfetch instead. Note than fastfetch will be run with '--logo none'.", - action="store_true", -) -parser.add_argument( - "--chroma", - required=False, - nargs="?", - help="Add this argument to chromakey a hexadecimal color from the video using ffmpeg using syntax of '--chroma ::' with being 0xRRGGBB with a 0x as opposed to a # e.g. '--chroma 0xc82044:0.1:0.1'", - type=str, -) -args = parser.parse_args() -args.sound_flag_given = check_sound_flag() # adding this to the args so that it considers whether the flag was given or not and if the flag is given what the sound file was. -args.chroma_flag_given = check_chroma_flag() - - -BASE_PATH = get_data_path() - -if not (BASE_PATH / "video").exists(): - os.mkdir(BASE_PATH / "video") -if not (BASE_PATH / "output").exists(): - os.mkdir(BASE_PATH / "output") - -if not pathlib.Path(args.filename).exists(): - print("Couldn't find file", pathlib.Path(args.filename)) - raise FileNotFoundError(args.filename) - - -if args.sound_flag_given: - if args.sound: - pass - else: - codec = check_codec_of_file(args.filename) - ext = get_ext_from_codec(codec) - args.sound_saved_path = str(BASE_PATH / f"output_audio.{ext}") - -if args.chroma_flag_given: - if args.chroma.startswith("#"): - sys.exit("Color for hex code starts with an '0x'! Not a '#'") - - - - -# check cache -old_filename = "" -should_update = False -try: - args_dict = {key: value for key, value in args._get_kwargs()} - if args.force_render: - should_update = True - else: - with open(BASE_PATH / "cache.json", "r") as f: - data = json.load(f) - for key, value in args_dict.items(): - try: - cached_value = data[key] - except KeyError: - should_update = True - break - if value != cached_value: # check if all options match - if key not in ( - "playback_rate", - "verbose", - "fast_fetch", - "benchmark", - "force_render" - ): # These arguments don't invalidate the cache. - print_verbose( - f"{key} INVALID! Will cache again. Value:{value} Cache:{cached_value}", - ) - should_update = True - print_verbose("Cache invalid, will cache again.") -except FileNotFoundError: - should_update = True - -if should_update: - print("Caching...") - - - -WIDTH = args.width -HEIGHT = args.height - - -# put cached frames here -frames: list[str] = [] - -# cache is invalid, re-render -if should_update: - print_verbose("SHOULD RENDER WITH CHAFA") - - # delete all old frames - shutil.rmtree(BASE_PATH / "video") - os.mkdir(BASE_PATH / "video") - - stdout = None if args.verbose else subprocess.DEVNULL - stderr = None if args.verbose else subprocess.STDOUT - - if args.chroma_flag_given: - subprocess.call( - [ - "ffmpeg", - "-i", - f"{args.filename}", - "-vf", - f"fps={args.framerate},format=rgba,chromakey={args.chroma}", - str(BASE_PATH / "video/%05d.png"), - ], - stdout=stdout, - stderr=stderr, - ) - else: - subprocess.call( - [ - "ffmpeg", - "-i", - f"{args.filename}", - "-vf", - f"fps={args.framerate},format=rgba", - str(BASE_PATH / "video/%05d.png"), - ], - stdout=stdout, - stderr=stderr, - ) - - print_verbose(args.sound_flag_given) - - if args.sound_flag_given: - if args.sound: # sound file given - print_verbose("Sound file to use:",args.sound) - source = pathlib.Path(args.sound) - dest = BASE_PATH / source.with_name(f"output_audio{source.suffix}") - shutil.copy(source, dest) - args.sound_saved_path = str(dest) - else: - print_verbose("No sound file specified, will attempt to extract it from video.") - codec = check_codec_of_file(args.filename) - ext = get_ext_from_codec(codec) - audio_file = extract_audio_from_file(args.filename, ext) - print_verbose("Extracted audio file.") - - args.sound_saved_path = str(audio_file) - - print_verbose(args.sound_saved_path) - - - # If the new anim frames is shorter than the old one, then in /output there will be both new and old frames. Empty the directory to fix this. - shutil.rmtree(BASE_PATH / "output") - os.mkdir(BASE_PATH / "output") - - print_verbose("Emptied the output folder.") - - # get the frames - animation_files = os.listdir(BASE_PATH / "video") - animation_files.sort() - for i, f in enumerate(animation_files): - # TODO: REMOVE THIS - #print_verbose(f"- Frame: {f}") - - # f = 00001.png - chafa_args = args.chafa_arguments.strip() - chafa_args += " --format symbols" # Fixes https://github.com/Notenlish/anifetch/issues/1 - - path = BASE_PATH / "video" / f - chafa_cmd = [ - "chafa", - *chafa_args.split(" "), - # "--color-space=rgb", - f"--size={WIDTH}x{HEIGHT}", - path.as_posix(), - ] - frame = subprocess.check_output( - chafa_cmd, - text=True, - ) - - with open((BASE_PATH / "output" / f).with_suffix(".txt"), "w") as file: - file.write(frame) - - # if wanted aspect ratio doesnt match source, chafa makes width as high as it can, and adjusts height accordingly. - # AKA: even if I specify 40x20, chafa might give me 40x11 or something like that. - if i == 0: - HEIGHT = len(frame.splitlines()) - frames.append(frame) # dont question this, I need frames to have at least a single item -else: - # just use cached - for filename in os.listdir(BASE_PATH / "output"): - path = BASE_PATH / "output" / filename - with open(path, "r") as file: - frame = file.read() - frames.append(frame) - break # dont question this, I just need frames to have a single item - HEIGHT = len(frames[0].splitlines()) - - with open(BASE_PATH / "cache.json", "r") as f: - data = json.load(f) - - if args.sound_flag_given: - args.sound_saved_path = data["sound_saved_path"] - else: - args.sound_saved_path = None - -print_verbose("-----------") - - -# print_verbose("ARGS FOR SAVING CACHE.JSON", args) - -# save the caching arguments -with open(BASE_PATH / "cache.json", "w") as f: - args_dict = {key: value for key, value in args._get_kwargs()} - json.dump(args_dict, f, indent=2) - - - -# Get the fetch output(neofetch/fastfetch) -if not args.fast_fetch: - # Get Neofetch Output - fetch_output = subprocess.check_output( - ["neofetch"], shell=True, text=True - ).splitlines() - for i, line in enumerate(fetch_output): - line = line[4:] # i forgot what this does, but its important iirc. - fetch_output[i] = line - - fetch_output.pop(0) - fetch_output.pop(0) - fetch_output.pop(0) - fetch_output.pop(-1) -else: - fetch_output = subprocess.check_output( - ["fastfetch", "--logo", "none", "--pipe", "false"], text=True - ).splitlines() - - -# modifying template to account for the width of the chafa animation. -chafa_rows = frames[0].splitlines() -template = [] -for y, fetch_line in enumerate(fetch_output): - output = "" - try: - chafa_line = chafa_rows[y] - except IndexError: - chafa_line = "" - - width_to_offset = GAP + WIDTH - - # Removing the dust that may appear with a padding - output = f"{(PAD_LEFT + (GAP * 2)) * ' '}{' ' * width_to_offset}{fetch_line}\n" - max_width = shutil.get_terminal_size().columns - cleaned_line = (output.rstrip() + ' ' * (max_width - len(output.rstrip())))[:max_width] + '\n' - template.append(cleaned_line) - -# writing the tempate to a file. -with open(BASE_PATH / "template.txt", "w") as f: - f.writelines(template) - # I just need to move this down, and also apply that padding thingy(for lines that dont have chafa anim) - # so basically repeat what I have done but this time its for layout. - # If I do this then I can get rid of the layout padding code on the last part. because the layout will already be fixed. -print_verbose("Template updated") - -# for defining the positions of the cursor, that way I can set cursor pos and only redraw a portion of the text, not the entire text. -TOP = 2 -LEFT = PAD_LEFT -RIGHT = WIDTH + PAD_LEFT -BOTTOM = HEIGHT # + TOP - -script_dir = os.path.dirname(__file__) -script_path = os.path.join(script_dir, "loop-anifetch.sh") -if not os.path.exists(script_path): - script_path = "loop-anifetch.sh" - - -RIGHT = WIDTH + PAD_LEFT -BOTTOM = HEIGHT # + TOP - - -if not args.benchmark: - try: - - framerate_to_use = args.playback_rate - if args.sound_flag_given: - framerate_to_use = args.framerate # ignore wanted playback rate so that desync doesn't happen - - script_args = [ - "bash", - script_path, - str(framerate_to_use), - str(TOP), - str(LEFT), - str(RIGHT), - str(BOTTOM), - ] - if args.sound_flag_given: # if user requested for sound to be played - script_args.append(str(args.sound_saved_path)) - - print(script_args) - #raise SystemExit - subprocess.call( - script_args, - text=True, - ) - except KeyboardInterrupt: - # Reset the terminal in case it doesnt render the user inputted text after Ctrl+C - subprocess.call(["stty", "sane"]) -else: - print(f"It took {time.time() - st} seconds.") - -if pathlib.Path(BASE_PATH / "video").exists(): - shutil.rmtree(BASE_PATH / "video") # no need to keep the video frames. diff --git a/benchmark.py b/benchmark.py deleted file mode 100644 index ead0c4e..0000000 --- a/benchmark.py +++ /dev/null @@ -1,70 +0,0 @@ -import subprocess -import time -import os -import shutil - - -def time_check_nocache(args, count: int): - args = args.split(" ") - - if args[1] == "anifetch.py": - args.append("--force-render") - - st = time.time() - for _ in range(count): - subprocess.call(args) - return time.time() - st - - -def time_check_cache(args, count: int): - args = args.split(" ") - subprocess.call(args) # gen cache - - st = time.time() - for _ in range(count): - subprocess.call(args) - return time.time() - st - - -count = 10 -common_args = "-f example.mp4 -W 60 -H 30 -r 10 --benchmark" - -print("NEOFETCH") - -neofetch = time_check_cache("neofetch", count) - -print("FASTFETCH") - -fastfetch = time_check_cache("fastfetch --logo none", count) - -print("ANIFETCH NOCACHE (Neofetch)") - -anifetch_nocache_neo = time_check_nocache(f"python3 anifetch.py {common_args}", count) - -print("ANIFETCH CACHED (Neofetch)") - -anifetch_cached_neo = time_check_cache(f"python3 anifetch.py {common_args}", count) - -print("ANIFETCH NOCACHE (Fastfetch)") - -anifetch_nocache_fast = time_check_nocache(f"python3 anifetch.py {common_args} -ff", count) - -print("ANIFETCH CACHED (Fastfetch)") - -anifetch_cached_fast = time_check_cache(f"python3 anifetch.py {common_args} -ff", count) - - -print("Neofetch") -print(neofetch) -print("Fastfetch") -print(fastfetch) - -print("Anifetch(No Cache)(neofetch)") -print(anifetch_nocache_neo) -print("Anifetch(Cached)(neofetch)") -print(anifetch_cached_neo) - -print("Anifetch(No Cache)(fastfetch)") -print(anifetch_nocache_fast) -print("Anifetch(Cached)(fastfetch)") -print(anifetch_cached_fast) diff --git a/example-config.conf b/configs/example-config.conf similarity index 100% rename from example-config.conf rename to configs/example-config.conf diff --git a/anifetch.webp b/docs/anifetch.webp similarity index 100% rename from anifetch.webp rename to docs/anifetch.webp diff --git a/example-logo.txt b/example-logo.txt deleted file mode 100644 index 6fd2dc0..0000000 --- a/example-logo.txt +++ /dev/null @@ -1,2 +0,0 @@ - ​${c1} -N \ No newline at end of file diff --git a/loop-anifetch.sh b/loop-anifetch.sh deleted file mode 100644 index 2998c70..0000000 --- a/loop-anifetch.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash - -FRAME_DIR="$HOME/.local/share/anifetch/output" - -# Check for FRAMERATE input -if [[[ $# -ne 5 ] || [ $# -ne 6 ]]]; then - echo "Usage: " - exit 1 -fi - -framerate=$1 -top=$2 -left=$3 -right=$4 -bottom=$5 -soundname=$6 - -num_lines=$((bottom - top)) - -# Hide cursor -tput civis - -# TODO: the cursor should be placed at end when the user does ctrl + c -trap "tput cnorm; if [ -t 0 ]; then stty echo; fi; tput sgr0; tput cup $(tput lines) 0; exit 0" SIGINT - -clear - -for (( i=0; i 0" | bc -l) )); then - sleep "$sleep_duration" - fi - - i=$((i + 1)) - done -done diff --git a/nix/packages/anifetch.nix b/nix/packages/anifetch.nix index 2a085ca..e87f7f8 100644 --- a/nix/packages/anifetch.nix +++ b/nix/packages/anifetch.nix @@ -16,6 +16,7 @@ in pkgs.python3Packages.setuptools ]; + # TODO: need to add the platformdirs python dependency dependencies = [ pkgs.bc pkgs.chafa diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9c754d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "anifetch" +version = "0.1.0" +description = "Animated terminal fetch with video/audio support." +authors = [{name = "Notenlish"}, {name = "Immelancholy"}, {name = "Gallophostrix", email = "gallophostrix@gmail.com"}] +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.7" +dependencies = [ + "platformdirs" + # Example : "rich>=13.0.0" +] + +[project.scripts] +anifetch = "anifetch.__init__:main" + +[tool.setuptools] +packages = ["anifetch"] +package-dir = { "" = "src" } + +[tool.setuptools.package-data] +anifetch = [ + "assets/*", + "anifetch-static-resize2.sh" +] diff --git a/src/anifetch/__init__.py b/src/anifetch/__init__.py new file mode 100644 index 0000000..97c4961 --- /dev/null +++ b/src/anifetch/__init__.py @@ -0,0 +1,17 @@ +""" +Anifetch package initialization module. +""" + +from .core import run_anifetch +from .cli import parse_args + + +def main(): + args = parse_args() + + if args.filename: + run_anifetch(args) + + +if __name__ == "__main__": + main() diff --git a/src/anifetch/__main__.py b/src/anifetch/__main__.py new file mode 100644 index 0000000..3640e12 --- /dev/null +++ b/src/anifetch/__main__.py @@ -0,0 +1,8 @@ +""" +Anifetch main module for running the application as a dev only. +""" + +from . import main + +if __name__ == "__main__": + main() diff --git a/src/anifetch/anifetch-static-resize2.sh b/src/anifetch/anifetch-static-resize2.sh new file mode 100644 index 0000000..64dd2a2 --- /dev/null +++ b/src/anifetch/anifetch-static-resize2.sh @@ -0,0 +1,258 @@ +#!/bin/bash + +FRAME_DIR="$HOME/.local/share/anifetch/output" +STATIC_TEMPLATE_FILE="$HOME/.local/share/anifetch/template.txt" + +# check for num of args +if [[ $# -ne 6 && $# -ne 7 ]]; then + echo "Usage: $0 [soundname]" + exit 1 +fi + +framerate=$1 +top=$2 +left=$3 +right=$4 +bottom=$5 +template_actual_width=$6 +soundname=$7 + +num_lines=$((bottom - top)) +sleep_time=$(echo "scale=4; 1 / $framerate" | bc) +adjusted_sleep_time=$(echo "$sleep_time / $num_lines" | bc -l) + +# Buffer for storing processed template, only compute when necessary +declare -a template_buffer +# last terminal width +last_term_width=0 + +# Hide cursor +tput civis + +# exit handler +cleanup() { + tput cnorm # Show cursor + if [ -t 0 ]; then + stty echo # Restore echo + fi + tput sgr0 # Reset terminal attributes + tput cup $(tput lines) 0 # Move cursor to bottom + exit 0 +} +trap cleanup SIGINT SIGTERM +stty -echo # won't allow ^C to be printed when SIGINT signal comes. + +# Process the template once and store in memory buffer +process_template() { + local term_width=$(tput cols) + + # Only reprocess if terminal width has changed + if [ "$term_width" -ne "$last_term_width" ]; then + # Clear the buffer + template_buffer=() + + # Make sure we're working with a valid width + if [ "$term_width" -lt 1 ]; then + term_width=1 + fi + + # Process each line and store in buffer + local line_num=0 + while IFS= read -r line || [ -n "$line" ]; do + # Process the line and store in buffer + template_buffer[$line_num]=$(truncate_line "$line" "$term_width") + ((line_num++)) + done < "$STATIC_TEMPLATE_FILE" + + # Update the last terminal width + last_term_width=$term_width + fi +} + +# Function to truncate a line while preserving the ANSI color codes +truncate_line() { + local line="$1" + local max_width="$2" + + # Don't process empty lines + if [ -z "$line" ]; then + echo -n "" + return + fi + + # Remove ANSI codes to get visible text length + local stripped=$(printf "%b" "$line" | sed 's/\x1b\[[0-9;]*m//g') + + # Calculate visible length while also considering Unicode characters + local visible_length=$(printf "%b" "$stripped" | wc -m) + + if [ "$visible_length" -le "$max_width" ]; then + # Line is already short so add terminal control to prevent wrapping + printf "%b\r" "$line" + else + # keep ANSI codes while truncating + local result="" + local current_length=0 + local i=0 + local char + local in_escape=0 + local escape_sequence="" + + while [ $current_length -lt "$max_width" ] && [ $i -lt ${#line} ]; do + char="${line:$i:1}" + + if [ $in_escape -eq 1 ]; then + escape_sequence+="$char" + if [[ "$char" =~ [a-zA-Z] ]]; then + # End of escape sequence + result+="$escape_sequence" + escape_sequence="" + in_escape=0 + fi + else + if [ "$char" = $'\e' ]; then + # Start of escape sequence + escape_sequence="$char" + in_escape=1 + else + result+="$char" + ((current_length++)) + fi + fi + + ((i++)) + done + + # Add any remaining escape sequences + if [ -n "$escape_sequence" ]; then + result+="$escape_sequence" + fi + + # Add reset code at the end for proper termination + result+="\033[0m" + + # prevent wrapping + printf "%b\r" "$result" + fi +} + +# Draw the static template +draw_static_template() { + # Process template first + process_template + + # Clear screen and position cursor + tput clear + tput cup $top 0 + + # Print the buffer in one go(faster than one by line) + for line in "${template_buffer[@]}"; do + # Clear to end of line before printing to eliminate any potential artifacts + tput el + printf "%b\n" "$line" + done +} + +resize_requested=false +resize_in_progress=false +resize_delay=0.2 # seconds +last_resize_time=0 + +on_resize() { + resize_requested=true +} + +process_resize_if_needed() { + current_time=$(date +%s.%N) + + # If we're already processing a resize, don't start working on another one + if [ "$resize_in_progress" = true ]; then + return + fi + + if [ "$resize_requested" = false ]; then + return + fi + + # Check if enough time has passed since last resize + if [ "$last_resize_time" != "0" ]; then + time_diff=$(echo "$current_time - $last_resize_time" | bc) + if (( $(echo "$time_diff < $resize_delay" | bc -l) )); then + # Not enough time has passed, wait more + return + fi + fi + + # we can process + resize_in_progress=true + resize_requested=false + last_resize_time=$current_time + + new_width=$(tput cols) + new_height=$(tput lines) + + # calculate the new template + process_template + + tput clear + tput cup $top 0 + + # Print buffer all at once with terminal control codes to prevent wrapping + for line in "${template_buffer[@]}"; do + # First clear to end of line to ensure no artifacts + tput el + printf "%b\n" "$line" + done + + # Reset flag + resize_in_progress=false +} + +# Trap the SIGWINCH signal (window size change) +trap 'on_resize' SIGWINCH + +# Initial draw +draw_static_template + +# Start audio if sound is provided +if [ $# -eq 7 ]; then + ffplay -nodisp -autoexit -loop 0 -loglevel quiet "$soundname" & +fi + +i=1 +wanted_epoch=0 +start_time=$(date +%s.%N) +while true; do + + for frame in $(ls "$FRAME_DIR" | sort -n); do + lock=true + current_top=$top + while IFS= read -r line; do + tput cup "$current_top" "$left" + echo -ne "$line" + current_top=$((current_top + 1)) + if [[ $current_top -gt $bottom ]]; then + break + fi + done < "$FRAME_DIR/$frame" + lock=false + + wanted_epoch=$(echo "$i/$framerate" | bc -l) + + # current time in seconds (with fractional part) + now=$(date +%s.%N) + + # Calculate how long to sleep to stay in sync + sleep_duration=$(echo "$wanted_epoch - ($now - $start_time)" | bc -l) + + # Only sleep if ahead of schedule + if (( $(echo "$sleep_duration > 0" | bc -l) )); then + sleep "$sleep_duration" + fi + + i=$((i + 1)) + + process_resize_if_needed + done + sleep 0.005 +done \ No newline at end of file diff --git a/example.mp4 b/src/anifetch/assets/example.mp4 similarity index 100% rename from example.mp4 rename to src/anifetch/assets/example.mp4 diff --git a/example.png b/src/anifetch/assets/example.png similarity index 100% rename from example.png rename to src/anifetch/assets/example.png diff --git a/src/anifetch/cli.py b/src/anifetch/cli.py new file mode 100644 index 0000000..61779a0 --- /dev/null +++ b/src/anifetch/cli.py @@ -0,0 +1,105 @@ +""" +Anifetch CLI module for parsing command line arguments. +""" + +import argparse + + +def parse_args(): + parser = argparse.ArgumentParser( + prog="Anifetch", + description="Allows you to use neofetch with video in terminal (using chafa).", + ) + parser.add_argument( + "-b", + "--benchmark", + default=False, + help="For testing. Runs Anifetch without actually starting the animation and times how long it took. Also does the same for neofetch and fastfetch. Checks anifetch for both cached and not cached version.", + action="store_true", + ) + parser.add_argument( + "filename", + help="Video file to use (default: example.mp4)", + type=str, + ) + parser.add_argument( + "-w", + "-W", + "--width", + default=40, + help="Width of the chafa animation.", + type=int, + ) + parser.add_argument( + "-H", + "--height", + default=20, + help="Height of the chafa animation.", + type=int, + ) + parser.add_argument("-v", "--verbose", default=False, action="store_true") + parser.add_argument( + "-r", + "--framerate", + default=10, + help="Sets the framerate when extracting frames from ffmpeg.", + type=int, + ) + parser.add_argument( + "-pr", + "--playback-rate", + default=10, + help="Ignored when a sound is playing so that desync doesn't happen. Sets the playback rate of the animation. Not to be confused with the 'framerate' option. This basically sets for how long the script will wait before rendering new frame, while the framerate option affects how many frames are generated via ffmpeg.", + ) + parser.add_argument( + "-s", + "--sound", + required=False, + nargs="?", + help="Optional. Will playback a sound file while displaying the animation. If you give only -s without any sound file it will attempt to extract the sound from the video.", + type=str, + ) + parser.add_argument( + "-fr", + "--force-render", + default=False, + action="store_true", + help="Disabled by default. Anifetch saves the filename to check if the file has changed, if the name is same, it won't render it again. If enabled, the video will be forcefully rendered, whether it has the same name or not. Please note that it only checks for filename, if you changed the framerate then you'll need to force render.", + ) + parser.add_argument( + "-C", + "--center-mode", + default=False, + action="store_true", + help="Disabled by default. Use this argument to center the animation relative to the fetch output. Note that centering may slow down the execution.", + ) + parser.add_argument( + "-c", + "--chafa-arguments", + default="--symbols ascii --fg-only", + help="Specify the arguments to give to chafa. For more informations, use 'chafa --help'", + ) + parser.add_argument( + "--force", + default=False, + help="Add this argument if you want to use neofetch even if it is deprecated.", + action="store_true", + ) + parser.add_argument( + "-ff", + "--fast-fetch", + default=False, + help="Add this argument if you want to use fastfetch instead. Note than fastfetch will be run with '--logo none'.", + action="store_true", + ) + parser.add_argument( + "--chroma", + required=False, + nargs="?", + help="Add this argument to chromakey a hexadecimal color from the video using ffmpeg using syntax of '--chroma ::' with being 0xRRGGBB with a 0x as opposed to a # e.g. '--chroma 0xc82044:0.1:0.1'", + type=str, + ) + + args = parser.parse_args() + + return args diff --git a/src/anifetch/core.py b/src/anifetch/core.py new file mode 100644 index 0000000..7e454ba --- /dev/null +++ b/src/anifetch/core.py @@ -0,0 +1,400 @@ +""" +Anifetch core module for running the animation. +""" + +import json +import os +import pathlib +import shutil +import subprocess +import errno +import sys +import time +from .utils import ( + check_codec_of_file, + extract_audio_from_file, + get_text_length_of_formatted_text, + get_ext_from_codec, + get_data_path, + default_asset_presence_check, + get_video_dimensions, + get_neofetch_status, + render_frame, + print_verbose, +) + + +GAP = 2 +PAD_LEFT = 4 + + +def run_anifetch(args): + st = time.time() + + args.sound_flag_given = bool(args.sound) + args.chroma_flag_given = args.chroma is not None + neofetch_status = get_neofetch_status() + + BASE_PATH = get_data_path() + + VIDEO_DIR = BASE_PATH / "video" + OUTPUT_DIR = BASE_PATH / "output" + CACHE_PATH = BASE_PATH / "cache.json" + ASSET_PATH = BASE_PATH / "assets" + + (ASSET_PATH).mkdir(parents=True, exist_ok=True) + (VIDEO_DIR).mkdir(exist_ok=True) + (OUTPUT_DIR).mkdir(exist_ok=True) + + default_asset_presence_check(ASSET_PATH) + + filename = pathlib.Path(args.filename) + + # If the filename is relative, check if it exists in the assets directory. + if not filename.exists(): + candidate = ASSET_PATH / filename + if candidate.exists(): + filename = candidate + else: + print( + f"[ERROR] File not found: {args.filename}\nMake sure the file exists or that it is in the correct directory.", + file=sys.stderr, + ) + sys.exit(1) + + args.filename = str(filename.resolve()) + + if args.sound_flag_given: + if args.sound: + pass + else: + codec = check_codec_of_file(args.filename) + try: + ext = get_ext_from_codec(codec) + except ValueError as e: + print(f"[ERROR] {e}") + sys.exit(1) + + args.sound_saved_path = str(BASE_PATH / f"output_audio.{ext}") + + if args.chroma and args.chroma.startswith("#"): + print("[ERROR] Use '0x' prefix for chroma color, not '#'.", file=sys.stderr) + sys.exit(1) + + # check cache + should_update = False + try: + args_dict = {key: value for key, value in args._get_kwargs()} + if args.force_render: + should_update = True + else: + with open(CACHE_PATH, "r") as f: + data = json.load(f) + for key, value in args_dict.items(): + try: + cached_value = data[key] + except KeyError: + should_update = True + break + if value != cached_value: # check if all options match + if key not in ( + "playback_rate", + "verbose", + "center-mode", + "fast_fetch", + "benchmark", + "force_render", + ): # These arguments don't invalidate the cache. + print_verbose( + f"{key} INVALID! Will cache again. Value:{value} Cache:{cached_value}", + ) + should_update = True + except FileNotFoundError: + should_update = True + + if should_update: + print("Caching...") + + WIDTH = args.width + # automatically calculate height if not given + if "--height" not in sys.argv and "-H" not in sys.argv: + try: + vid_w, vid_h = get_video_dimensions(ASSET_PATH / args.filename) + except RuntimeError as e: + print(f"[ERROR] {e}") + sys.exit(1) + + ratio = vid_h / vid_w + HEIGHT = round(args.width * ratio) + else: + HEIGHT = args.height + + # Get the fetch output(neofetch/fastfetch) + if not args.fast_fetch: + if ( + neofetch_status == "wrapper" and args.force + ) or neofetch_status == "neofetch": + # Get Neofetch Output + fetch_output = subprocess.check_output( + ["neofetch", "--off"], text=True + ).splitlines() + + elif neofetch_status == "uninstalled": + print( + "Neofetch is not installed. Please install Neofetch or Fastfetch.", + file=sys.stderr, + ) + sys.exit(1) + + else: + print( + "Neofetch is deprecated. Try fastfetch using '-ff' argument or force neofetch to run using '--force' argument.", + file=sys.stderr, + ) + sys.exit(1) + else: + try: + fetch_output = subprocess.check_output( + ["fastfetch", "--logo", "none", "--pipe", "false"], text=True + ).splitlines() + except FileNotFoundError as e: + if e.errno == errno.ENOENT: + print( + "The command Fastfetch was not found. You probably forgot to install it. You can install it by going to here: https://github.com/fastfetch-cli/fastfetch\n If you installed Fastfetch but it still doesn't work, check your PATH." + ) + raise SystemExit + else: + raise Exception(e) + + # put cached frames here + frames: list[str] = [] + + # copy the fetch output to the fetch_lines variable + fetch_lines = fetch_output[:] + len_fetch = len(fetch_lines) + + # cache is invalid, re-render + if should_update: + print_verbose("SHOULD RENDER WITH CHAFA") + + # delete all old frames + shutil.rmtree(VIDEO_DIR, ignore_errors=True) + (VIDEO_DIR).mkdir(exist_ok=True) + + stdout = None if args.verbose else subprocess.DEVNULL + stderr = None if args.verbose else subprocess.PIPE + + try: + result_ffmpeg = subprocess.run( + [ + "ffmpeg", + "-i", + f"{args.filename}", + "-vf", + f"fps={args.framerate},format=rgba", + str(BASE_PATH / "video/%05d.png"), + ], + stdout=stdout, + stderr=stderr, + text=True, + ) + except FileNotFoundError as e: + if e.errno == errno.ENOENT: + print( + "The command Ffmpeg was not found. You probably forgot to install it. You can install it by going to here: https://ffmpeg.org/download.html\n If you installed Ffmpeg but it still doesn't work, check your PATH." + ) + raise SystemExit + else: + raise + else: + if result_ffmpeg.returncode != 0: + print(f"[ERROR] ffmpeg failed: {result_ffmpeg.stderr}") + sys.exit(1) + + print_verbose(args.sound_flag_given) + + if args.sound_flag_given: + if args.sound: # sound file given + print_verbose("Sound file to use:", args.sound) + source = pathlib.Path(args.sound) + dest = BASE_PATH / source.with_name(f"output_audio{source.suffix}") + shutil.copy(source, dest) + args.sound_saved_path = str(dest) + else: + print_verbose( + "No sound file specified, will attempt to extract it from video." + ) + codec = check_codec_of_file(args.filename) + ext = get_ext_from_codec(codec) + audio_file = extract_audio_from_file(BASE_PATH, args.filename, ext) + print_verbose("Extracted audio file.") + + args.sound_saved_path = str(audio_file) + + print_verbose(args.sound_saved_path) + + # If the new anim frames is shorter than the old one, then in /output there will be both new and old frames. + # Empty the directory to fix this. + shutil.rmtree(OUTPUT_DIR) + os.mkdir(OUTPUT_DIR) + + print_verbose("Emptied the output folder.") + + # get the frames + animation_files = os.listdir(VIDEO_DIR) + animation_files.sort() + for i, f in enumerate(animation_files): + # f = 00001.png + chafa_args = args.chafa_arguments.strip() + chafa_args += " --format symbols" # Fixes https://github.com/Notenlish/anifetch/issues/1 + + path = VIDEO_DIR / f + frame = render_frame(path, WIDTH, HEIGHT, chafa_args) + + chafa_lines = frame.splitlines() + + if args.center_mode: + # centering the fetch output or the chafa animation if needed. + len_chafa = len(chafa_lines) + + if ( + len_chafa < len_fetch + ): # if the chafa animation is shorter than the fetch output + pad = (len_fetch - len_chafa) // 2 + remind = (len_fetch - len_chafa) % 2 + chafa_lines.pop() # don't ask me why, the last line always seems to be empty + chafa_lines = ( + [" " * WIDTH] * pad + + chafa_lines + + [" " * WIDTH] * (pad + remind) + ) + + elif ( + len_fetch < len_chafa + ): # if the chafa animation is longer than the fetch output + pad = (len_chafa - len_fetch) // 2 + remind = (len_chafa - len_fetch) % 2 + fetch_lines = ( + [" " * WIDTH] * pad + + fetch_output + + [" " * WIDTH] * (pad + remind) + ) + + if i == 0: + # updating the HEIGHT variable from the first frame + HEIGHT = len(chafa_lines) + else: + if i == 0: + len_chafa = len(chafa_lines) + pad = abs(len_fetch - len_chafa) // 2 + remind = abs(len_fetch - len_chafa) % 2 + HEIGHT = len(chafa_lines) + (2 * pad + remind) * WIDTH + + frames.append("\n".join(chafa_lines)) + + with open((OUTPUT_DIR / f).with_suffix(".txt"), "w") as file: + file.write("\n".join(chafa_lines)) + + # if wanted aspect ratio doesnt match source, chafa makes width as high as it can, and adjusts height accordingly. + # AKA: even if I specify 40x20, chafa might give me 40x11 or something like that. + else: + # just use cached + for filename in os.listdir(OUTPUT_DIR): + path = OUTPUT_DIR / filename + with open(path, "r") as file: + frame = file.read() + frames.append(frame) + break # first frame used for the template and the height + + if args.center_mode: + len_chafa = len(frame.splitlines()) + if len_fetch < len_chafa: + pad = (len_chafa - len_fetch) // 2 + remind = (len_chafa - len_fetch) % 2 + fetch_lines = ( + [" " * WIDTH] * pad + fetch_output + [" " * WIDTH] * (pad + remind) + ) + + with open(BASE_PATH / "frame.txt", "w") as f: + f.writelines(frames) + + HEIGHT = len(frames[0].splitlines()) + + # reloarding the cached output + with open(CACHE_PATH, "r") as f: + data = json.load(f) + + if args.sound_flag_given: + args.sound_saved_path = data["sound_saved_path"] + else: + args.sound_saved_path = None + + print_verbose("-----------") + + # save the caching arguments + with open(CACHE_PATH, "w") as f: + args_dict = {key: value for key, value in args._get_kwargs()} + json.dump(args_dict, f, indent=2) + + if len(fetch_lines) == 0: + raise Exception("fetch_lines has no items in it:", fetch_lines) + + template = [] + for fetch_line in fetch_lines: + output = f"{' ' * (PAD_LEFT + GAP)}{' ' * WIDTH}{' ' * GAP}{fetch_line}" + template.append(output + "\n") + + # Only do this once instead of for every line. + output_width = get_text_length_of_formatted_text(output) + template_actual_width = output_width # TODO: maybe this should instead be the text_length_of_formatted_text(cleaned_line) + + # writing the tempate to a file. + with open(BASE_PATH / "template.txt", "w") as f: + f.writelines(template) + print_verbose("Template updated") + + # for defining the positions of the cursor, that way I can set cursor pos and only redraw a portion of the text, not the entire text. + TOP = 2 + LEFT = PAD_LEFT + RIGHT = WIDTH + PAD_LEFT + BOTTOM = HEIGHT + + bash_script_name = "anifetch-static-resize2.sh" + script_dir = pathlib.Path(__file__).parent + bash_script_path = script_dir / bash_script_name + + if not args.benchmark: + try: + framerate_to_use = args.playback_rate + if args.sound_flag_given: + framerate_to_use = ( + args.framerate + ) # ignore wanted playback rate so that desync doesn't happen + + script_args = [ + "bash", + str(bash_script_path), + str(framerate_to_use), + str(TOP), + str(LEFT), + str(RIGHT), + str(BOTTOM), + str(template_actual_width), + ] + if args.sound_flag_given: # if user requested for sound to be played + script_args.append(str(args.sound_saved_path)) + + print_verbose(script_args) + # raise SystemExit + subprocess.call( + script_args, + text=True, + ) + except KeyboardInterrupt: + # Reset the terminal in case it doesnt render the user inputted text after Ctrl+C + subprocess.call(["stty", "sane"]) + else: + print(f"It took {time.time() - st} seconds.") + + if pathlib.Path(VIDEO_DIR).exists(): + shutil.rmtree(VIDEO_DIR) # no need to keep the video frames. diff --git a/src/anifetch/utils.py b/src/anifetch/utils.py new file mode 100644 index 0000000..f9e929b --- /dev/null +++ b/src/anifetch/utils.py @@ -0,0 +1,176 @@ +# animfetch/utils.py + +""" +Anifetch utility module for common functions used across the application. +""" +import pathlib +import re +import subprocess +import sys +from importlib.resources import files +from platformdirs import user_data_dir +import shutil + +appname = "anifetch" +appauthor = "anifetch" + + +def print_verbose(verbose, *msg): + if verbose: + print(*msg) + + +def strip_ansi(text): + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + return ansi_escape.sub("", text) + + +def get_text_length_of_formatted_text(text: str): + text = strip_ansi(text) + return len(text) + + +def get_ext_from_codec(codec): + codec_extension_map = { + "aac": "m4a", + "mp3": "mp3", + "opus": "opus", + "vorbis": "ogg", + "pcm_s16le": "wav", + "flac": "flac", + "alac": "m4a", + } + if not codec or codec.lower() not in codec_extension_map: + raise ValueError(f"Unsupported or unknown codec: {codec}") + return codec_extension_map[codec.lower()] + + +def check_codec_of_file(file: str): + try: + ffprobe_cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=codec_name", + "-of", + "default=nokey=1:noprint_wrappers=1", + file, + ] + codec = subprocess.check_output(ffprobe_cmd, text=True).strip() + return codec + except subprocess.CalledProcessError: + print_verbose(True, f"Error: Unable to determine codec for file {file}.") + return None + + +def extract_audio_from_file(BASE_PATH, file: str, extension): + audio_file = BASE_PATH / f"output_audio.{extension}" + extract_cmd = [ + "ffmpeg", + "-i", + file, + "-y", + "-vn", + "-c:a", + "copy", + "-loglevel", + "quiet", + audio_file, + ] + try: + subprocess.run(extract_cmd, check=True) + return audio_file + except subprocess.CalledProcessError: + print_verbose(True, f"Error: Unable to extract audio from file {file}.") + return None + + +def get_data_path(): + # on linux: /home/[username]/.local/share/anifetch + base = pathlib.Path( + user_data_dir(appname, appauthor) + ) + base.mkdir(parents=True, exist_ok=True) + return base + + +def default_asset_presence_check(asset_dir): + if not any(asset_dir.iterdir()): + packaged_asset = files("anifetch.assets") / "example.mp4" + shutil.copy(str(packaged_asset), asset_dir / "example.mp4") + + +def get_neofetch_status(): # will still save the rendered chafa in cache in any case + try: + # check the result of running neofetch with --version + result = subprocess.run( + ["neofetch", "--version"], capture_output=True, text=True + ) + output = result.stdout + result.stderr + if ( + "fastfetch" in output.lower() + ): # if the output contains "fastfetch", return wrapper + return "wrapper" + else: + return "neofetch" # neofetch works + except FileNotFoundError: + return "uninstalled" # neofetch is not installed + + +def render_frame(path, width, height, chafa_args: str) -> str: + """ + Renders a single frame using chafa. + + Args: + path (Path): Path to the image file. + width (int): Target width for rendering. + height (int): Target height for rendering. + chafa_args (str): Additional CLI arguments for chafa (space-separated). + + Returns: + str: Rendered frame as ASCII text. + + Raises: + SystemExit: If chafa fails to render the frame. + """ + chafa_cmd = [ + "chafa", + *chafa_args.strip().split(), + "--format", + "symbols", # Fix issue #1 by forcing consistent rendering + f"--size={width}x{height}", + path.as_posix(), + ] + + try: + return subprocess.check_output(chafa_cmd, text=True) + except subprocess.CalledProcessError as e: + print( + f"[ERROR] chafa rendering failed.\nCommand: {' '.join(chafa_cmd)}\nError: {e.stderr}", + file=sys.stderr, + ) + sys.exit(1) + + +def get_video_dimensions(filename): + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + filename, + ] + try: + output = subprocess.check_output(cmd, text=True).strip() + width_str, height_str = output.split("x") + return int(width_str), int(height_str) + except subprocess.CalledProcessError: + raise RuntimeError(f"Failed to get video dimensions: {filename}") diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/benchmark.py b/tools/benchmark.py new file mode 100644 index 0000000..de59677 --- /dev/null +++ b/tools/benchmark.py @@ -0,0 +1,77 @@ +# tests/benchmark.py + +""" +Benchmarking script for comparing the performance of Anifetch with Neofetch and Fastfetch. +""" + +import subprocess +import time +import shlex + + +def time_check( + command: str, count: int, preheat: bool = False +) -> tuple[str, float, float]: + args = shlex.split(command) + if preheat: # Preheat the cache by running the command once before timing + subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + start = time.time() + for _ in range(count): + subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + total = time.time() - start + average = total / count + return command, total, average + + +def run_all(): + count = 10 + video = "" # optionally: "-f example.mp4" + common_args = f"{video} -W 60 -r 10 --benchmark" + + tests = [ + ("Neofetch", "neofetch", True), + ("Fastfetch", "fastfetch", True), + ( + "Anifetch (no cache, Neofetch)", + f"python3 -m anifetch {common_args} --force-render", + False, + ), + ("Anifetch (cached, Neofetch)", f"python3 -m anifetch {common_args}", True), + ( + "Anifetch (no cache, Fastfetch)", + f"python3 -m anifetch {common_args} -ff --force-render", + False, + ), + ( + "Anifetch (cached, Fastfetch)", + f"python3 -m anifetch {common_args} -ff", + True, + ), + ] + + results = [] + print("Running benchmarks...\n(This may take a moment)\n") + + for name, cmd, preheat in tests: + print(f"Running: {name}...", end="", flush=True) + try: + _, total, avg = time_check(cmd, count, preheat) + results.append((name, total, avg)) + print(" done.") + except Exception as e: + results.append((name, None, None)) + print(f" failed: {e}") + + print("\n=== BENCHMARK RESULTS ===\n") + for name, total, avg in results: + if total is None: + print(f"{name}: failed") + else: + print( + f"{name}:\n Total time: {total:.2f} sec\n Avg per run: {avg:.2f} sec\n" + ) + + +if __name__ == "__main__": + run_all()