Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
D4n13l3k00 committed Aug 8, 2024
0 parents commit 68bb7cd
Show file tree
Hide file tree
Showing 13 changed files with 1,520 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
on:
push:
branches:
- master
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install
- name: Publish to PyPI
run: |
poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
poetry publish --build
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo

/dist
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<center>

# FlipAnimGen 📽️

## Dithering animation generator from video files for Flipper Zero written in Python 🐍

![CodeStyle](https://img.shields.io/badge/code%20style-black-black)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flipanimgen)

![GitHub contributors](https://img.shields.io/github/contributors/D4n13l3k00/flipanimgen)
![GitHub License](https://img.shields.io/github/license/D4n13l3k00/flipanimgen)

</center>

### Tested on 🧪

- [X] Windows 11
- [ ] Linux
- [ ] MacOS

### Installation 📦

Before installing the package, make sure you have installed [Python](https://www.python.org/downloads/) and
[Git](https://git-scm.com/downloads) on your system.

```bash
# Via pipx (recommend)
pip install pipx -U
pipx install flipanimgen

# Via pip (not recommended due to conflicts with other package versions)
pip install flipanimgen
```

### Usage 🎯

```bash
flipanimgen --help

# Example
flipanimgen -i input_animation.mp4 -o output_animation_folder
```

After running the command, the program will generate a folder with the animation frames in the specified directory.

Then you can use [FlipperAM](https://github.com/Ooggle/FlipperAnimationManager) to generate a manifest for adding the
animation to Flipper Zero

### License 📜

This project is licensed under the GNU AGPLv3 License - see the [LICENSE](LICENSE) file for details.
1 change: 1 addition & 0 deletions flipanimgen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# python -m flipanimgen -i <video_name> -o <output_folder>
3 changes: 3 additions & 0 deletions flipanimgen/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from flipanimgen.cli import cli

cli()
25 changes: 25 additions & 0 deletions flipanimgen/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import argparse

from flipanimgen.main import main


def cli():
parser = argparse.ArgumentParser(
description="FlipAnimGen - Dithering animation generator from video files for Flipper Zero written in Python"
)
parser.add_argument(
"--input", "-i", help="Input video path", type=str, required=True
)
parser.add_argument(
"--output",
"-o",
help="Output folder path for animation",
type=str,
default="flipanimgen-output",
required=False,
)
parsed_args = parser.parse_args()
if not parsed_args.input.endswith(".mp4"):
print("[red]Error:[/red] Input file must be .mp4")
exit(1)
main(parsed_args)
35 changes: 35 additions & 0 deletions flipanimgen/dithering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from numba import jit
import numpy as np


@jit(nopython=True)
def clamp(color):
return max(0, min(255, color))


@jit(nopython=True)
def floyd_steinberg_dither(image):
height, width = (image.shape[0], image.shape[1])

for y in range(0, height - 1):
for x in range(1, width - 1):
old_p = image[y, x]
new_p = np.round(old_p / 255.0) * 255
image[y, x] = new_p

quant_error_p = old_p - new_p

image[y, x + 1] = clamp(
image[y, x + 1] + quant_error_p * 0.4375
) # 7 / 16.0
image[y + 1, x - 1] = clamp(
image[y + 1, x - 1] + quant_error_p * 0.1875
) # 3 / 16.0
image[y + 1, x] = clamp(
image[y + 1, x] + quant_error_p * 0.3125
) # 5 / 16.0
image[y + 1, x + 1] = clamp(
image[y + 1, x + 1] + quant_error_p * 0.0625
) # 1 / 16.0

return image
93 changes: 93 additions & 0 deletions flipanimgen/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from pathlib import Path
from shutil import rmtree

import cv2
from rich import print

from flipanimgen.dithering import floyd_steinberg_dither
from flipanimgen.metadata import generate_meta
from flipanimgen.ofw import install_ofw, copy_anim_to_ofw, compile_animation


def main(args):
input_video_path = Path(args.input)

if not input_video_path.exists():
print(f"[bold red]Input video not found:[/] {input_video_path}")
return

temp_folder = Path.cwd() / "flipanimgen_temp"
print(f"[bold cyan]Temp folder:[/] {temp_folder}")
temp_folder.mkdir(exist_ok=True)

frames_folder = temp_folder / "animation"
print(f"[bold cyan]Frames folder:[/] {frames_folder}")
frames_folder.mkdir(exist_ok=True)

output_path = Path(args.output)
if output_path.exists():
rmtree(output_path, ignore_errors=True)
print(f"[bold cyan]Output path:[/] {output_path}")
output_path.mkdir(exist_ok=True)

cap = cv2.VideoCapture(str(input_video_path))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width, height = (
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
)
fps = cap.get(cv2.CAP_PROP_FPS)

current_frame = 0
print(f"[bold yellow]Processing frames:[/] {total}")
while cap.isOpened():
ret, frame = cap.read()
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

if width / height > 2:
new_width = height * 2
frame = frame[
:,
int((width - new_width) / 2) : int((width + new_width) / 2),
]
else:
new_height = width / 2
frame = frame[
int((height - new_height) / 2) : int((height + new_height) / 2),
:,
]

frame = cv2.resize(frame, (128, 64))

dithered_frame = floyd_steinberg_dither(frame)

cv2.imwrite(
str(frames_folder / f"frame_{current_frame}.png"),
dithered_frame,
)

current_frame += 1
print(f"[bold yellow]Progress:[/] {current_frame}/{total}", end="\r")
else:
break

print()

cap.release()
cv2.destroyAllWindows()

print(f"[bold yellow]Generating meta.txt[/]")
meta = generate_meta(
frames_count=current_frame,
framerate=int(fps),
)

with open(frames_folder / "meta.txt", "w") as f:
f.write(meta)

install_ofw(temp_folder)
copy_anim_to_ofw(frames_folder, temp_folder)
compile_animation(temp_folder, output_path)

rmtree(frames_folder, ignore_errors=True)
38 changes: 38 additions & 0 deletions flipanimgen/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
def generate_meta(frames_count: int, framerate: int) -> str:
duration = int(frames_count / framerate * 1000)
frames_order = " ".join(map(str, range(0, frames_count)))

return """
Filetype: Flipper Animation
Version: 1
Width: 128
Height: 64
Passive frames: {frames_count}
Active frames: 0
Frames order: {frames_order}
Active cycles: 0
Frame rate: {framerate}
Duration: {duration}
Active cooldown: 0
Bubble slots: 0
""".format(
frames_count=frames_count,
duration=duration,
frames_order=frames_order,
framerate=framerate,
).strip()


def get_manifest() -> str:
return """
Filetype: Flipper Animation Manifest
Version: 1
Name: animation
Min butthurt: 0
Max butthurt: 14
Min level: 1
Max level: 3
Weight: 8
""".strip()
97 changes: 97 additions & 0 deletions flipanimgen/ofw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import os
import shutil
import subprocess
from pathlib import Path
from typing import Union

from rich import print

from flipanimgen.metadata import get_manifest

OFW_DIR = "flipperzero-firmware-release"


def install_ofw(temp_path: Union[Path, str]):
if not isinstance(temp_path, Path):
temp_path = Path(temp_path)

ofw_path = temp_path / OFW_DIR

if ofw_path.exists():
print("[yellow]OFW already installed, skipping...[/]")
return

print("[yellow]Downloading OFW...[/]")
subprocess.run(
[
"git",
"clone",
"https://github.com/flipperdevices/flipperzero-firmware",
ofw_path,
"--depth=1",
]
)
print("[yellow]OFW downloaded![/]")


def copy_anim_to_ofw(new_animation_path: Union[Path, str], temp_path: Union[Path, str]):
if not isinstance(new_animation_path, Path):
new_animation_path = Path(new_animation_path)
if not isinstance(temp_path, Path):
temp_path = Path(temp_path)

ofw_path = temp_path / OFW_DIR
animations_path = ofw_path / "assets" / "dolphin" / "external"

print("[yellow]Cleaning up old animations...[/]")
for file in animations_path.iterdir():
if file.is_file():
file.unlink()
elif file.is_dir():
shutil.rmtree(file)

print("[yellow]Copying new animation...[/]")
shutil.copytree(str(new_animation_path), str(animations_path / "animation"))

print("[yellow]Generating manifest...[/]")
manifest_path = animations_path / "manifest.txt"
with manifest_path.open("w") as f:
f.write(get_manifest())


def compile_animation(temp_path: Union[Path, str], output_path: Union[Path, str]):
if not isinstance(temp_path, Path):
temp_path = Path(temp_path)
if not isinstance(output_path, Path):
output_path = Path(output_path)

print("[yellow]Compiling animation...[/]")

cwd = temp_path / OFW_DIR

old_dir = os.getcwd()
os.chdir(cwd)
command = [
"fbt.cmd" if os.name == "nt" else "fbt",
"icons",
"proto",
"dolphin_internal",
"dolphin_ext",
"resources",
]
subprocess.run(command)
os.chdir(old_dir)

ofw_path = temp_path / OFW_DIR
compiled_animations_path = ofw_path / "build"
build_name = compiled_animations_path.iterdir().__next__()
compiled_animations_path = (
compiled_animations_path
/ build_name
/ "assets"
/ "compiled"
/ "dolphin"
/ "animation"
)

shutil.copytree(str(compiled_animations_path), str(output_path), dirs_exist_ok=True)
Loading

0 comments on commit 68bb7cd

Please sign in to comment.