first commit

This commit is contained in:
Auric Vente 2024-08-02 03:03:03 -06:00
commit 0bce7df913
35 changed files with 22656 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
venv/*
output/*
*.pyc
__pycache__/
.mypy_cache/
Gifmaker.egg-info/
build/

1
LICENSE Normal file
View File

@ -0,0 +1 @@
Use this under good humor

972
README.md Normal file
View File

@ -0,0 +1,972 @@
<img src="media/image.jpg" width="380">
This is a Python program to produce images or videos.
It extracts random (or sequential) frames from a video or image.
It (optionally) places words somewhere on each frame.
Then joins all frames into an animation or image.
You can use many arguments to produce different kinds of results.
---
## Why?
It might be useful in the realm of human verification.
<img src="media/mean.gif">
And memes.
---
## Index
1. [Installation](#installation)
1. [Usage](#usage)
1. [Arguments](#arguments)
1. [More](#more)
---
<img src="media/installation.gif">
---
## Installation <a name="installation"></a>
### Using pipx
```sh
pipx install git+this_repo_url --force
```
Now you should have the `gifmaker` command available.
---
### Manual
Clone this repo, and get inside the directory:
```shell
git clone --depth 1 this_repo_url
cd gifmaker
```
Then create the virtual env:
```shell
python -m venv venv
```
Then install the dependencies:
```shell
venv/bin/pip install -r requirements.txt
```
Or simply run `scripts/venv.sh` to create the virtual env and install the dependencies.
There's a `scripts/test.sh` file that runs the program with some arguments to test if things are working properly.
---
<img src="media/usage.gif">
---
## Usage <a name="usage"></a>
Use the installed `gifmaker` command if you used `pipx`.
---
Or run `gifmaker/main.py` using the Python in the virtual env:
```shell
venv/bin/python -m gifmaker.main
```
There's a `run.sh` that does this.
---
You can provide a video or image path using the `--input` argument.
You also need to provide an output path:
```shell
gifmaker --input /path/to/video.webm --output /tmp/gifmaker
gifmaker --input /path/to/animated.gif --output /tmp/gifmaker
gifmaker --input /path/to/image.png --output /tmp/gifmaker
```
`webm`, `mp4`, `gif`, `jpg`, and `png` should work, and maybe other formats.
You can pass it a string of lines to use on each frame.
They are separated by `;` (semicolons).
```shell
gifmaker --words "Hello Brother ; Construct Additional Pylons"
```
It will make 2 frames, one per line.
If you want to use words and have some frames without them simply use more `;`.
---
You can use random words with `[random]`:
```shell
gifmaker --words "I Like [random] and [random]"
```
It will pick random words from a list of English words.
There are 4 kinds of random formats: `[random]`, `[RANDOM]`, `[Random]`, `[RanDom]`, and `[randomx]`.
The replaced word will use the case of those.
With `[random]` you get lower case, like `water`.
With `[RANDOM]` you get all caps, like `PLANET`.
With `[Random]` you get the first letter capitalized, like `The garden`.
With `[RanDom]` you get title case, like `The Machine`.
With `[randomx]` you get the exact item from the random list.
You can specify how many random words to generate by using a number:
For example `[Random 3]` might generate `Drivers Say Stories`.
---
You can multiply random commands by using numbers like `[x2]`.
For example:
```shell
--words "Buy [Random] [x2]"
```
This might produce: `Buy Sink ; Buy Plane`.
The multipliers need to be at the end of the line.
---
You can also generate random numbers with `[number]`.
This is a single digit from `0` to `9`.
For example, `[number]` might result in `3`.
You can specify the length of the number.
For example, `[number 3]` might result in `128`.
You can also use a number range by using two numbers.
For example, `[number 0 10]` will pick a random number from `0` to `10`.
```shell
--words "I rate it [number 0 10] out of 10"
```
---
If you want to repeat the previous line, use `[repeat]`:
For example: `--words "Buy Buttcoin ; [repeat]"`.
It will use that text in the first two frames.
You can also provide a number to specify how many times to repeat:
For example: `--words "Buy Buttcoin ; [repeat 2]"`.
The line will be shown in 3 frames (the original plus the 2 repeats).
A shortcut is also available: `[rep]` or `[rep 3]`.
---
You can use linebreaks with `\n`.
For example: `--words "Hello \n World"`.
Will place `Hello` where a normal line would be.
And then place `World` underneath it.
---
Another way to define an empty line is using `[empty]`.
For example: `hello ; world ; [empty]`.
This could be useful in `wordfile` to add empty lines at the end.
Else you can just add more `;` to `words`.
You can also use numbers like `[empty 3]`.
That would add 3 empty frames.
---
There's also `[date]` which can be used to print dates.
You can define any date format in it.
For example `[date %Y-%m-%d]` would print year-month-day.
You can see all format codes here: [datetime docs](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes).
If no format is used it defaults to `%H:%M:%S`.
---
There's also `[count]`.
The count starts at `0` and is increased on every `[count]`.
For example `--words "Match: [count] ; Nothing ; Match [count]"`.
It would print `Match: 1`, `Nothing`, and `Match: 2`.
You might want to print the count on every frame:
```shell
--words "[count]" --fillgen --frames 10
```
---
You can run `main.py` from anywhere in your system using its virtual env.
Relative paths should work fine.
---
If you provide an argument without flags, it will be used for `words`:
```sh
gifmaker --input image.png "What is that? ; AAAAA?"
```
It's a shortcut to avoid having to type `--words`.
---
Here's a fuller example:
```shell
gifmaker --input /videos/stuff.webm --fontsize 18 --delay 300 --width 600 --words "I want to eat ;; [Random] ; [repeat 2] ;" --format mp4 --bgcolor 0,0,0 --output stuff/videos
```
---
<img src="media/arguments.gif">
---
## Arguments <a name="arguments"></a>
You can use arguments like: `--delay 350 --width 500 --order normal`.
These modify how the file is going to be generated.
---
> **input** (Type: str | Default: None)
Path to a video or image to use as the source of the frames.
`webm`, `mp4`, `gif`, and even `jpg` or `png` should work.
For example: `--input stuff/cow.webm`.
`-i` is a shorter alias for this.
---
> **output** (Type: str | Default: None)
Directory path to save the generated file.
For example: `stuff/videos`.
It will use a random file name.
Using `gif`, `webm`, `mp4`, `jpg`, or `png` depending on the `format` argument.
Or you can enter the path plus the file name.
For example: `stuff/videos/cat.gif`.
The format is deduced by the extension (`gif`, `webm`, `mp4`, `jpg`, or `png`).
`-o` is a shorter alias for this.
---
> **words** (Type: str | Default: Empty)
The words string to use.
Lines are separated by `;`.
Each line is a frame.
Special words include `[random]` and `[repeat]`.
As described in [Usage](#usage).
---
> **wordfile** (Type: str | Default: None)
File to use as the source of word lines.
For example, a file can be like:
```
This is a line
I am a [random]
This is a line after an empty line
[repeat]
[empty]
```
Then you can point to it like:
```shell
--wordfile "/path/to/words.txt"
```
It will use word lines the same as with `--words`.
---
> **fillwords** (Type: flag | Default: False)
Fill the rest of the frames with the last word line.
If there are no more lines to use, it will re-use the last line.
For example:
```shell
--words "First Line; Last Line" --frames 5 --fillwords
```
First frame says "First Line".
Then it will use "Last Line" for the rest of the frames.
---
> **fillgen** (Type: flag | Default: False)
If this is enabled, the first line of words will be generated till the end of the frames.
For example:
```shell
gifmaker --words "[random] takes [count] at [date]" --fillgen --frames 5
```
---
> **separator** (Type: str | Default: ";")
The character to use as the line separator in `words`.
This also affects `randomlist`.
---
> **delay** (Type: int | Default: 700)
The delay between frames. In milliseconds.
A smaller `delay` = A faster animation.
---
> **frames** (Type: int | Default: 3)
The amount of frames to use.
This value has a higher priority than the other frame count methods.
---
> **framelist** (Type: str | Default: Empty)
The specific list of frame indices to use.
The first frame starts at `0`.
For example `--framelist "2,5,2,0,3"`.
It will use those specific frames.
It also defines how long the animation is.
---
> **frameopts** (Type: str | Default: Empty)
Define the pool of frame indices when picking randomly.
For example: `--frameopts 0,11,22`.
---
> **left** (Type: int | Default: None)
Padding from the left edge to position the text.
---
> **right** (Type: int | Default: None)
Padding from the right edge to position the text.
---
> **top** (Type: int | Default: None)
Padding from the top edge to position the text.
---
> **bottom** (Type: int | Default: None)
Padding from the bottom edge to position the text.
---
You only need to set `left` or `right`, not both.
You only need to set `top` or `bottom`, not both.
If those are not set then the text is placed at the center.
If any of those is set to a negative value like `-100`, it will apply it from the center.
For example: `--top -100` would pull it a bit to the top from the center.
And `--right -100` would pull it a bit to the right from the center.
---
> **width** (Type: int | Default: None)
Fixed width of every frame.
If the height is not defined it will use an automatic one.
---
> **height** (Type: int | Default: None)
Fixed height of every frame.
If the width is not defined it will use an automatic one.
---
> **nogrow** (Type: flag | Default: False)
If this is enabled, the frames won't be resized if they'd be bigger than the original.
For instance, if the original has a width of `500` and you set `--width 600`.
It's a way to limit the values of `--width` and `--height`.
---
> **format** (Type: str | Default: "gif")
The format of the output file. Either `gif`, `webm`, `mp4`, `jpg`, or `png`.
This is only used when the output is not a direct file path.
For instance, if the output ends with `cat.gif` it will use `gif`.
If the output is a directory it will use a random name with the appropriate format.
---
> **order** (Type: str | Default: "random")
The order used to extract the frames.
Either `random` or `normal`.
`random` picks frames randomly.
`normal` picks frames in order starting from the first one.
`normal` loops back to the first frame if needed.
---
> **font** (Type: str | Default "sans")
The font to use for the text.
Either: `sans`, `serif`, `mono`, `bold`, `italic`, `cursive`, `comic`, or `nova`.
There is also `random` and `random2`.
First one is a random font, the other one is a random font on each frame.
You can also specify a path to a `ttf` file.
---
> **fontsize** (Type: int | Default: 60)
The size of the text.
---
> **fontcolor** (Type: str | Default: "255,255,255")
The color of the text.
This is a [color](#colors).
---
> **bgcolor** (Type: str | Default: None)
Add a background rectangle below the text.
This is a [color](#colors).
---
> **opacity** (Type: float | Default: 0.66)
From `0` to `1`.
The opacity level of the background rectangle.
The closer it is to `0` the more transparent it is.
---
> **padding** (Type: int | Default: 20)
The padding of the background rectangle.
This gives some spacing around the text.
---
> **radius** (Type: int | Default: 0)
The border radius of the background rectangle.
This is to give the rectangle rounded corners.
---
> **outline** (Type: str | Default: None)
Add an outline around the text.
In case you want to give the text more contrast.
This is a [color](#colors).
---
> **outlinewidth** (Type: int | Default: 2)
Width of the outline. It must be a number divisible by 2.
If it's not divisible by 2 it will pick the next number automatically.
---
> **no-outline-top** (Type: flag | Default: False)
> **no-outline-bottom** (Type: flag | Default: False)
> **no-outline-left** (Type: flag | Default: False)
> **no-outline-right** (Type: flag | Default: False)
Don't show specific lines from the outline.
---
> **align** (Type: str | Default: "center")
How to align the center when there are multiple lines.
Either `left`, `center`, or `right`.
---
> **wrap** (Type: int | Default: 35)
Split lines if they exceed this char length.
It creates new lines. Makes text bigger vertically.
---
> **nowrap** (Type: flag | Default: False)
Don't wrap the lines of words.
---
> **randomlist** (Type: str | Default: Empty)
Random words are selected from this list.
If the list is empty it will be filled with a long list of nouns.
You can specify the words to consider, separated by semicolons.
For example: `--randomlist "cat ; dog ; nice cow ; big horse"`.
---
> **randomfile** (Type: str | Default: List of nouns)
Path to a text file with the random words to use.
This is a simple text file with each word or phrase in its own line.
For example:
```
dog
a cow
horse
```
Then you point to it: `--randomfile "/path/to/animals.txt"`.
---
> **repeatrandom** (Type: flag | Default: False)
If this is enabled, random words can be repeated at any time.
Else it will cycle through them randomly without repetitions.
---
> **loop** (Type: int | Default 0)
How to loop gif renders.
`-1` = No loop
`0` = Infinite loop
`1 or more` = Specific number of loops
---
> **filter** (Type: str | Default: "none")
A color filter that is applied to each frame.
The filters are: `hue1`, `hue2` .. up to `hue8`, and `anyhue`, `anyhue2`.
And also: `gray`, `blur`, `invert`, `random`, `random2`, `none`.
`random` picks a random filter for all frames.
`random2` picks a random filter on every frame.
`anyhue` is like `random` but limited to the hue effects.
`anyhue2` is like `random2` but is limited to the hue effects.
---
> **filteropts** (Type: str | Default: Empty)
This defines the pool of available filters to pick randomly.
This applies when `filter` is `random` or `random2`.
For example: `--filteropts hue1,hue2,hue3,gray`.
---
> **repeatfilter** (Type: flag | Default: False)
If this is enabled, random filters can be repeated at any time.
Else it will cycle through them randomly without repetitions.
---
> **remake** (Type: flag | Default: False)
Use this if you only want to re-render the frames.
It re-uses all the frames, resizes, and renders again.
It doesn't do the rest of the operations.
For example: `--input /path/to/file.gif --remake --width 500 --delay 300`.
For instance, you can use this to change the `width` or `delay` of a rendered file.
---
> **descender** (Type: flag | Default: False)
If enabled, the descender height will add extra space to the bottom of text.
This is relevant when adding a background or an outline.
This means words like `Ayyy` get covered completely, top of `A` and bottom of `y`.
The default is to ignore the descender to ensure consistent placement of text.
---
> **seed** (Type: int | Default: None)
> **frameseed** (Type: int | Default: None)
> **wordseed** (Type: int | Default: None)
> **filterseed** (Type: int | Default: None)
> **colorseed** (Type: int | Default: None)
The random component can be seeded.
This means that if you give it a value, it will always act the same.
This can be useful if you want to replicate results.
There are multiple random generators:
One takes care of picking frames and is controlled by `frameseed`.
One takes care of picking words and numbers and is controlled by `wordseed`.
One takes care of picking filters and is controlled by `filterseed`.
One takes care of picking colors and is controlled by `colorseed`.
If those are not defined, then it will assign the generic `seed` (if defined).
If no seed is defined then it won't use seeds and be truly random (the default).
---
> **deepfry** (Type: flag | Default: False)
Apply heavy `jpeg` compression to all frames.
Use to distort the result for whatever reason.
---
> **word-color-mode** (Type: str | Default: "normal")
Either `normal` or `random`.
In `normal` it will avoid fetching random colors on the same lines.
For instance if the words are `First line [x2] ; Second line [x2]`.
And the colors are set to `--fontcolor light2 --bgcolor darkfont2`.
It will use the same colors for the first 2 frames, and then other colors for the rest.
Instead of picking random colors on each frame.
This is to avoid the text being too aggresive visually.
This can be disabled with `random`, which will fetch random colors on each frame.
---
If a number argument has a default you can use `p` and `m` operators.
`p` means `plus` while `m` means `minus`.
For example, since `fontsize` has a default of `20`.
You can do `--fontsize p1` or `--fontsize m1`.
To get `21` or `19`.
---
### Colors <a name="colors"></a>
Some arguments use the color format.
This can be 3 numbers from `0` to `255`, separated by commas.
It uses the `RGB` format.
`0,0,0` would be black, for instance.
The value can also be `light` or `dark`.
These will get a random light or dark color.
The value can also be `light2` or `dark2`.
These will get a random light or dark color on each frame.
Names are also supported, like `green`, `black`, `red`.
It can also be `font` to use the same color as the font.
It can also be `lightfont` and `darkfont`.
These pick contrasts based on the current font color.
For example if the font color is light red, the contrast would be dark redish.
`lightfont2` and `darkfont2` do the same but on each frame.
---
<img src="media/more.gif">
---
## More Information<a name="more"></a>
### Scripts
You can make `TOML` files that define the arguments to use.
Provide the path of a script like this: `--script "/path/to/script.toml"`.
For example, a script can look like this:
```toml
words = "Disregard [Random] ; [repeat] ; Acquire [Random] ; [repeat] ;"
fontcolor = "44,80,200"
fontsize = 80
bgcolor = "0,0,0"
bottom = 0
right = 0
```
---
### Functions
You can write shell functions to make things faster by using templates.
For example here's a `fish` function:
```js
function funstuff
gifmaker \
--input /path/to/some/file.png --words "$argv is [Random] [x5]" \
--bgcolor random_dark2 --fontcolor random_light2 \
--top 0 --fontsize 22 --filter random2 --width 600
end
```
This is added in `~/.config/fish/config.fish`.
Source the config after adding the function:
```shell
source ~/.config/fish/config.fish
```
Then you can run: `funstuff Grog`.
In this case it will do `Grogg is [Random]` 5 times.
Using all the other arguments that are specific to look good on that image.
---
### Python
You might want to interface through another Python program.
Here's some snippets that might help:
```python
import asyncio
from pathlib import Path
# Arguments shared by all functions
gifmaker_common = [
"/usr/bin/gifmaker",
"--width", 350,
"--output", "/tmp/gifmaker",
"--nogrow",
]
# Add quotes around everything and join
def join_command(command):
return " ".join(f'"{arg}"' for arg in command)
# Get the command list and turn it into a string
def gifmaker_command(args):
command = gifmaker_common.copy()
command.extend(args)
return join_command(command)
# You can have multiple functions like this
def generate_something(who):
command = gifmaker_command([
"--input", get_path("describe.jpg"),
"--words", f"{who} is\\n[Random] [x5]",
"--filter", "anyhue2",
"--opacity", 0.8,
"--fontsize", 66,
"--delay", 700,
"--padding", 50,
"--fontcolor", "light2",
"--bgcolor", "black",
])
run_gifmaker(command)
# This is an async example
async def run_gifmaker(command):
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
shell=True,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
print(f"Error: {stderr.decode()}")
return
await upload(Path(stdout.decode().strip()))
```

0
gifmaker/__init__.py Normal file
View File

164
gifmaker/argparser.py Normal file
View File

@ -0,0 +1,164 @@
# Standard
import re
import sys
import argparse
from typing import List, Any, Dict, Union
from pathlib import Path
class ArgParser:
# Generic class to get arguments from the terminal
def __init__(self, title: str, argdefs: Dict[str, Any], aliases: Dict[str, List[str]], obj: Any):
parser = argparse.ArgumentParser(description=title)
argdefs["string_arg"] = {"nargs": "*"}
for key in argdefs:
item = argdefs[key]
if key == "string_arg":
names = [key]
else:
name = ArgParser.under_to_dash(key)
# Add -- and - formats
names = [f"--{name}", f"-{name}"]
name2 = key.replace("-", "")
# Check without dashes
if name2 != name:
names.extend([f"--{name2}", f"-{name2}"])
if key in aliases:
names += aliases[key]
tail = {key: value for key,
value in item.items() if value is not None}
parser.add_argument(*names, **tail)
self.args = parser.parse_args()
self.obj = obj
def string_arg(self) -> str:
return " ".join(self.args.string_arg)
def get_list(self, attr: str, value: str, vtype: Any, separator: str) -> List[Any]:
try:
lst = list(map(vtype, map(str.strip, value.split(separator))))
except BaseException:
sys.exit(f"Failed to parse '--{attr}'")
return lst
def normal(self, attr: str) -> None:
value = getattr(self.args, attr)
if value is not None:
self.set(attr, value)
def commas(self, attr: str, vtype: Any, allow_string: bool = False, is_tuple: bool = False) -> None:
value = getattr(self.args, attr)
if value is not None:
if ("," in value) or (not allow_string):
lst = self.get_list(attr, value, vtype, ",")
if is_tuple:
self.set(attr, tuple(lst))
else:
self.set(attr, lst)
else:
self.set(attr, value)
def path(self, attr: str) -> None:
value = getattr(self.args, attr)
if value is not None:
self.set(attr, ArgParser.resolve_path(value))
# Allow p1 and m1 formats
def number(self, attr: str, vtype: Any, allow_zero: bool = False, duration: bool = False) -> None:
value = getattr(self.args, attr)
if value is None:
return
value = str(value)
num = value
op = ""
if value.startswith("p") or value.startswith("m"):
op = value[0]
num = value[1:]
if duration:
num = self.parse_duration(num)
try:
if vtype == int:
num = int(num)
elif vtype == float:
num = float(num)
except BaseException:
sys.exit(f"Failed to parse '{attr}'")
default = self.get(attr)
if op == "p":
num = default + num
elif op == "m":
num = default - num
err = f"Value for '{attr}' is too low"
if num == 0:
if not allow_zero:
sys.exit(err)
elif num < 0:
sys.exit(err)
self.set(attr, num)
def get(self, attr: str) -> Any:
return getattr(self.obj, attr)
def set(self, attr: str, value: Any) -> None:
setattr(self.obj, attr, value)
def parse_duration(self, time_string: str) -> str:
match = re.match(r"(\d+(\.\d+)?)([smh]+)", time_string)
if match:
value, _, unit = match.groups()
value = float(value)
if unit == "ms":
time_string = str(int(value))
elif unit == "s":
time_string = str(int(value * 1000))
elif unit == "m":
time_string = str(int(value * 60 * 1000))
elif unit == "h":
time_string = str(int(value * 60 * 60 * 1000))
return time_string
@staticmethod
def dash_to_under(s: str) -> str:
return s.replace("-", "_")
@staticmethod
def under_to_dash(s: str) -> str:
return s.replace("_", "-")
@staticmethod
def full_path(path: Union[Path, str]) -> Path:
return Path(path).expanduser().resolve()
@staticmethod
def resolve_path(path: Union[Path, str]) -> Path:
path = ArgParser.full_path(path)
if path.is_absolute():
return ArgParser.full_path(path)
else:
return ArgParser.full_path(Path(Path.cwd(), path))

457
gifmaker/config.py Normal file
View File

@ -0,0 +1,457 @@
# Modules
from . import utils
from .argparser import ArgParser
# Standard
import json
import codecs
import textwrap
import random
from argparse import Namespace
from typing import List, Union, Dict, Tuple, Any
from PIL import ImageFont # type: ignore
from pathlib import Path
class Configuration:
# Class to hold all the configuration of the program
# It also interfaces with ArgParser and processes further
def __init__(self) -> None:
self.delay = 700
self.input: Union[Path, None] = None
self.output: Union[Path, None] = None
self.randomfile: Union[Path, None] = None
self.frames: Union[int, None] = None
self.left: Union[int, None] = None
self.right: Union[int, None] = None
self.top: Union[int, None] = None
self.bottom: Union[int, None] = None
self.width: Union[int, None] = None
self.height: Union[int, None] = None
self.words: List[str] = []
self.wordfile: Union[Path, None] = None
self.randomlist: List[str] = []
self.separator = ";"
self.format = "gif"
self.order = "random"
self.font = "sans"
self.fontsize = 60
self.fontcolor: Union[Tuple[int, int, int], str] = (255, 255, 255)
self.bgcolor: Union[Tuple[int, int, int], str, None] = None
self.outline: Union[Tuple[int, int, int], str, None] = None
self.outlinewidth = 2
self.no_outline_left = False
self.no_outline_right = False
self.no_outline_top = False
self.no_outline_bottom = False
self.opacity = 0.66
self.padding = 20
self.radius = 0
self.align = "center"
self.script: Union[Path, None] = None
self.loop = 0
self.remake = False
self.filterlist: List[str] = []
self.filteropts: List[str] = []
self.filter = "none"
self.framelist: List[str] = []
self.frameopts: List[str] = []
self.repeatrandom = False
self.repeatfilter = False
self.fillwords = False
self.fillgen = False
self.nogrow = False
self.wrap = 35
self.nowrap = False
self.verbose = False
self.descender = False
self.seed: Union[int, None] = None
self.frameseed: Union[int, None] = None
self.wordseed: Union[int, None] = None
self.filterseed: Union[int, None] = None
self.colorseed: Union[int, None] = None
self.deepfry = False
self.vertical = False
self.horizontal = False
self.word_color_mode = "normal"
class Internal:
# The path where the main file is located
root: Union[Path, None] = None
# The path where the fonts are located
fontspath: Union[Path, None] = None
# List to keep track of used random words
randwords: List[str] = []
# Counter for [count]
wordcount = 0
# Last font color used
last_fontcolor: Union[Tuple[int, int, int], None] = None
# Random generators
random_frames: Union[random.Random, None] = None
random_words: Union[random.Random, None] = None
random_filters: Union[random.Random, None] = None
random_colors: Union[random.Random, None] = None
# Last word printed
last_words = ""
# Last colors used
last_colors: List[Tuple[int, int, int]] = []
# Response string
response = ""
# Strings for info
rgbstr = "3 numbers from 0 to 255, separated by commas. Names like 'yellow' are also supported"
commastr = "Separated by commas"
# Information about the program
manifest: Dict[str, str]
# Argument definitions
arguments: Dict[str, Any] = {
"input": {"type": str, "help": "Path to a video or image file. Separated by commas"},
"words": {"type": str, "help": "Lines of words to use on the frames"},
"wordfile": {"type": str, "help": "Path of file with word lines"},
"delay": {"type": str, "help": "The delay in ms between frames"},
"left": {"type": int, "help": "Left padding"},
"right": {"type": int, "help": "Right padding"},
"top": {"type": int, "help": "Top padding"},
"bottom": {"type": int, "help": "Bottom padding"},
"width": {"type": int, "help": "Width to resize the frames"},
"height": {"type": int, "help": "Height to resize the frames"},
"frames": {"type": int, "help": "Number of frames to use if no words are provided"},
"output": {"type": str, "help": "Output directory to save the file"},
"format": {"type": str, "choices": ["gif", "webm", "mp4", "jpg", "png"], "help": "The format of the output file"},
"separator": {"type": str, "help": "Character to use as the separator"},
"order": {"type": str, "choices": ["random", "normal"], "help": "The order to use when extracting the frames"},
"font": {"type": str, "help": "The font to use for the text"},
"fontsize": {"type": str, "help": "The size of the font"},
"fontcolor": {"type": str, "help": f"Text color. {rgbstr}"},
"bgcolor": {"type": str, "help": f"Add a background rectangle for the text with this color. {rgbstr}"},
"outline": {"type": str, "help": f"Add an outline around the text with this color. {rgbstr}"},
"outlinewidth": {"type": str, "help": "The width of the outline"},
"opacity": {"type": str, "help": "The opacity of the background rectangle"},
"padding": {"type": str, "help": "The padding of the background rectangle"},
"radius": {"type": str, "help": "The border radius of the background"},
"align": {"type": str, "choices": ["left", "center", "right"], "help": "How to align the center when there are multiple lines"},
"randomlist": {"type": str, "help": "List of words to consider for random words"},
"randomfile": {"type": str, "help": "Path to a list of words to consider for random words"},
"script": {"type": str, "help": "Path to a TOML file that defines the arguments to use"},
"loop": {"type": int, "help": "How to loop a gif render"},
"remake": {"action": "store_true", "help": "Re-render the frames to change the width or delay"},
"filter": {"type": str,
"help": "Color filter to apply to frames",
"choices": ["hue1", "hue2", "hue3", "hue4", "hue5", "hue6", "hue7", "hue8",
"anyhue", "anyhue2", "gray", "grey", "blur", "invert", "random", "random2", "none"]},
"filterlist": {"type": str, "help": f"Filters to use per frame. {commastr}"},
"filteropts": {"type": str, "help": f"The list of allowed filters when picking randomly. {commastr}"},
"framelist": {"type": str, "help": f"List of frame indices to use. {commastr}"},
"frameopts": {"type": str, "help": f"The list of allowed frame indices when picking randomly. {commastr}"},
"repeatrandom": {"action": "store_true", "help": "Repeating random words is ok"},
"repeatfilter": {"action": "store_true", "help": "Repeating random filters is ok"},
"fillwords": {"action": "store_true", "help": "Fill the rest of the frames with the last word line"},
"fillgen": {"action": "store_true", "help": "Generate the first line of words till the end of frames"},
"nogrow": {"action": "store_true", "help": "Don't resize if the frames are going to be bigger than the original"},
"wrap": {"type": str, "help": "Split line if it exceeds this char length"},
"nowrap": {"action": "store_true", "help": "Don't wrap lines"},
"no_outline_left": {"action": "store_true", "help": "Don't draw the left outline"},
"no_outline_right": {"action": "store_true", "help": "Don't draw the right outline"},
"no_outline_top": {"action": "store_true", "help": "Don't draw the top outline"},
"no_outline_bottom": {"action": "store_true", "help": "Don't draw the bottom outline"},
"verbose": {"action": "store_true", "help": "Print more information like time performance"},
"descender": {"action": "store_true", "help": "Apply the height of the descender to the bottom padding of the text"},
"seed": {"type": int, "help": "Seed to use when using any random value"},
"frameseed": {"type": int, "help": "Seed to use when picking frames"},
"wordseed": {"type": int, "help": "Seed to use when picking words"},
"filterseed": {"type": int, "help": "Seed to use when picking filters"},
"colorseed": {"type": int, "help": "Seed to use when picking colors"},
"deepfry": {"action": "store_true", "help": "Compress the frames heavily"},
"vertical": {"action": "store_true", "help": "Append images vertically"},
"horizontal": {"action": "store_true", "help": "Append images horizontally"},
"arguments": {"action": "store_true", "help": "Print argument information"},
"word-color-mode": {"type": str, "choices": ["normal", "random"], "help": "Color mode for words"},
}
aliases = {
"input": ["--i", "-i"],
"output": ["--o", "-o"],
}
def parse_args(self) -> None:
v_title = self.Internal.manifest["title"]
v_version = self.Internal.manifest["version"]
v_info = f"{v_title} {v_version}"
self.Internal.arguments["version"] = {"action": "version",
"help": "Check the version of the program", "version": v_info}
ap = ArgParser(self.Internal.manifest["title"], self.Internal.arguments, self.Internal.aliases, self)
# ---
if getattr(ap.args, "arguments"):
self.Internal.response = self.arguments_json()
return
# ---
ap.path("script")
self.check_script(ap.args)
# ---
string_arg = ap.string_arg()
if string_arg:
ap.args.words = string_arg
# ---
ap.number("fontsize", int)
ap.number("delay", int, duration=True)
ap.number("opacity", float, allow_zero=True)
ap.number("padding", int, allow_zero=True)
ap.number("radius", int, allow_zero=True)
ap.number("outlinewidth", int)
ap.number("wrap", int)
# ---
ap.commas("framelist", int)
ap.commas("frameopts", int)
ap.commas("filterlist", str)
ap.commas("filteropts", str)
ap.commas("fontcolor", int, allow_string=True, is_tuple=True)
ap.commas("bgcolor", int, allow_string=True, is_tuple=True)
ap.commas("outline", int, allow_string=True, is_tuple=True)
# ---
normals = ["left", "right", "top", "bottom", "width", "height", "format", "order",
"font", "frames", "loop", "separator", "filter", "remake", "repeatrandom",
"repeatfilter", "fillwords", "nogrow", "align", "nowrap", "no_outline_left",
"no_outline_right", "no_outline_top", "no_outline_bottom", "verbose", "fillgen",
"descender", "seed", "frameseed", "wordseed", "filterseed", "colorseed",
"deepfry", "vertical", "horizontal", "word_color_mode"]
for normal in normals:
ap.normal(normal)
# ---
paths = ["input", "output", "wordfile", "randomfile"]
for path in paths:
ap.path(path)
# ---
self.fill_paths()
self.check_config(ap.args)
def check_config(self, args: Namespace) -> None:
def separate(value: str) -> List[str]:
return [codecs.decode(utils.clean_lines(item), "unicode-escape")
for item in value.split(self.separator)]
if not self.input:
utils.exit("You need to provide an input file")
return
if not self.output:
utils.exit("You need to provide an output path")
return
if (not self.input.exists()) or (not self.input.is_file()):
utils.exit("Input file does not exist")
return
if self.wordfile:
if not self.wordfile.exists() or not self.wordfile.is_file():
utils.exit("Word file does not exist")
return
self.read_wordfile()
elif args.words:
self.words = separate(args.words)
if args.randomlist:
self.randomlist = separate(args.randomlist)
assert isinstance(self.randomfile, Path)
if not self.randomfile.exists() or not self.randomfile.is_file():
utils.exit("Word file does not exist")
return
if not self.nowrap:
self.wrap_text("words")
if self.vertical or self.horizontal:
if self.format not in ["jpg", "png"]:
self.format = "png"
self.set_random()
def wrap_text(self, attr: str) -> None:
lines = getattr(self, attr)
if not lines:
return
new_lines = []
for line in lines:
lines = line.split("\n")
wrapped = [textwrap.fill(x, self.wrap) for x in lines]
new_lines.append("\n".join(wrapped))
setattr(self, attr, new_lines)
def check_script(self, args: Namespace) -> None:
if self.script is None:
return
data = utils.read_toml(Path(self.script))
if data:
for key in data:
k = ArgParser.dash_to_under(key)
setattr(args, k, data[key])
def read_wordfile(self) -> None:
if self.wordfile:
self.words = self.wordfile.read_text().splitlines()
def fill_root(self, main_file: str) -> None:
self.Internal.root = Path(main_file).parent
self.Internal.fontspath = ArgParser.full_path(Path(self.Internal.root, "fonts"))
def get_manifest(self):
with open(Path(self.Internal.root, "manifest.json"), "r") as file:
self.Internal.manifest = json.load(file)
def fill_paths(self) -> None:
assert isinstance(self.Internal.root, Path)
if not self.randomfile:
self.randomfile = ArgParser.full_path(Path(self.Internal.root, "nouns.txt"))
def get_color(self, attr: str) -> Tuple[int, int, int]:
value = getattr(self, attr)
rgb: Union[Tuple[int, int, int], None] = None
set_config = False
if isinstance(value, str):
if value == "light":
rgb = utils.random_light()
set_config = True
elif value == "light2":
rgb = utils.random_light()
elif value == "dark":
rgb = utils.random_dark()
set_config = True
elif value == "dark2":
rgb = utils.random_dark()
elif (value == "font") and isinstance(self.Internal.last_fontcolor, tuple):
rgb = self.Internal.last_fontcolor
elif value == "lightfont" and isinstance(self.Internal.last_fontcolor, tuple):
rgb = utils.light_contrast(self.Internal.last_fontcolor)
set_config = True
elif value == "lightfont2" and isinstance(self.Internal.last_fontcolor, tuple):
rgb = utils.light_contrast(self.Internal.last_fontcolor)
elif value == "darkfont" and isinstance(self.Internal.last_fontcolor, tuple):
rgb = utils.dark_contrast(self.Internal.last_fontcolor)
set_config = True
elif value == "darkfont2" and isinstance(self.Internal.last_fontcolor, tuple):
rgb = utils.dark_contrast(self.Internal.last_fontcolor)
else:
rgb = utils.color_name(value)
elif isinstance(value, (list, tuple)) and len(value) >= 3:
rgb = (value[0], value[1], value[2])
ans = rgb or (100, 100, 100)
if attr == "fontcolor":
self.Internal.last_fontcolor = ans
if set_config:
setattr(self, attr, rgb)
return ans
def set_random(self) -> None:
def set_rng(attr: str, rng_name: str) -> None:
value = getattr(self, attr)
if value is not None:
rand = random.Random(value)
elif self.seed is not None:
rand = random.Random(self.seed)
else:
rand = random.Random()
setattr(self.Internal, rng_name, rand)
set_rng("frameseed", "random_frames")
set_rng("wordseed", "random_words")
set_rng("filterseed", "random_filters")
set_rng("colorseed", "random_colors")
def get_font(self) -> ImageFont.FreeTypeFont:
fonts = {
"sans": "Roboto-Regular.ttf",
"serif": "RobotoSerif-Regular.ttf",
"mono": "RobotoMono-Regular.ttf",
"italic": "Roboto-Italic.ttf",
"bold": "Roboto-Bold.ttf",
"cursive": "Pacifico-Regular.ttf",
"comic": "ComicNeue-Regular.ttf",
"nova": "NovaSquare-Regular.ttf",
}
def random_font() -> str:
return random.choice(list(fonts.keys()))
if self.font == "random":
font = random_font()
font_file = fonts[font]
self.font = font
elif self.font == "random2":
font = random_font()
font_file = fonts[font]
elif ".ttf" in self.font:
font_file = str(ArgParser.resolve_path(Path(self.font)))
elif self.font in fonts:
font_file = fonts[self.font]
else:
font_file = fonts["sans"]
assert isinstance(self.Internal.fontspath, Path)
path = Path(self.Internal.fontspath, font_file)
return ImageFont.truetype(path, size=self.fontsize)
def arguments_json(self) -> str:
filter_out = ["string_arg", "arguments"]
new_dict = {
key: {k: v for k, v in value.items() if (k != "type" and k != "action")}
for key, value in self.Internal.arguments.items() if key not in filter_out
}
for key in new_dict:
if hasattr(self, key):
new_dict[key]["value"] = getattr(self, key)
return json.dumps(new_dict)
# Main configuration object
config = Configuration()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

102
gifmaker/main.py Normal file
View File

@ -0,0 +1,102 @@
# Modules
from .config import config
from . import words
from . import media
from . import utils
# Standard
import time
# Performance
last_time = 0.0
def get_time() -> float:
return time.time()
def show_seconds(name: str, start: float, end: float) -> None:
num = round(start - end, 3)
label = utils.colortext("blue", name)
utils.msg(f"{label}: {num} seconds")
def check_time(name: str) -> None:
if not config.verbose:
return
global last_time
now = get_time()
show_seconds(name, now, last_time)
last_time = now
def main() -> None:
global last_time
start_time = get_time()
last_time = start_time
# Fill some paths based on root path
config.fill_root(__file__)
config.get_manifest()
# Check the provided arguments
config.parse_args()
check_time("Parse Args")
# Print response if not empty then exit
if config.Internal.response:
utils.respond(config.Internal.response)
return
# Process words
words.process_words()
check_time("Process Words")
# Extract the required frames from the file
frames = media.get_frames()
check_time("Get Frames")
if not frames:
utils.msg("No frames")
return
if config.remake:
# Only resize the frames
frames = media.resize_frames(frames)
check_time("Resize Frames")
else:
# Apply filters to all the frames
frames = media.apply_filters(frames)
check_time("Apply Filters")
# Deep Fry frames if enabled
frames = media.deep_fry(frames)
check_time("Deep Fry")
# Add the words to the frames
frames = media.word_frames(frames)
check_time("Word Frames")
# Resize the frames based on width
frames = media.resize_frames(frames)
check_time("Resize Frames")
# Render and save the output
output = media.render(frames)
check_time("Render")
# End stats
if config.verbose:
utils.msg("")
label = utils.colortext("blue", "Frames")
utils.msg(f"{label}: {len(frames)}")
show_seconds("Total", get_time(), start_time)
utils.msg("")
# Print the output path as the response
utils.respond(str(output))
if __name__ == "__main__":
main()

7
gifmaker/manifest.json Normal file
View File

@ -0,0 +1,7 @@
{
"version": "1.0.0",
"title": "Gifmaker",
"program": "gifmaker",
"license": "Custom",
"author": "madprops"
}

511
gifmaker/media.py Normal file
View File

@ -0,0 +1,511 @@
# Modules
from .config import config
from . import utils
from . import words
# Libraries
import imageio # type: ignore
import numpy as np
import numpy.typing as npt
from PIL import Image, ImageFilter, ImageOps, ImageDraw, ImageFont # type: ignore
# Standard
import random
from io import BytesIO
from pathlib import Path
from typing import List, Dict, Union
def get_frames() -> List[Image.Image]:
count_frames()
assert isinstance(config.frames, int)
assert isinstance(config.input, Path)
frames = []
path = config.input
ext = utils.get_extension(path)
if (ext == "jpg") or (ext == "jpeg") or (ext == "png"):
reader = imageio.imread(path)
max_frames = 1
mode = "image"
elif ext == "gif":
reader = imageio.mimread(path)
max_frames = len(reader)
mode = "gif"
else:
reader = imageio.get_reader(path)
max_frames = reader.count_frames()
mode = "video"
num_frames = max_frames if config.remake else config.frames
order = "normal" if (config.remake or config.framelist) else config.order
framelist = config.framelist if config.framelist else range(max_frames)
current = 0
# Sometimes it fails to read the frames so it needs more tries
for _ in range(0, num_frames * 25):
if order == "normal":
index = framelist[current]
elif order == "random":
if config.frameopts:
index = random.choice(config.frameopts)
else:
assert isinstance(config.Internal.random_frames, random.Random)
index = config.Internal.random_frames.randint(0, len(framelist))
try:
if mode == "image":
img = reader
elif mode == "video":
img = reader.get_data(index)
elif mode == "gif":
img = reader[index]
frames.append(to_pillow(img))
except Exception as e:
pass
if len(frames) == num_frames:
break
if order == "normal":
current += 1
if current >= len(framelist):
current = 0
if mode == "video":
reader.close()
return frames
def draw_text(frame: Image.Image, line: str) -> Image.Image:
draw = ImageDraw.Draw(frame, "RGBA")
font = config.get_font()
data = get_text_data(frame, line, font)
get_colors = True
if line == config.Internal.last_words:
if config.word_color_mode == "normal":
get_colors = False
if not config.Internal.last_colors:
get_colors = True
if get_colors:
fontcolor = config.get_color("fontcolor")
bgcolor = config.get_color("bgcolor")
ocolor = config.get_color("outline")
else:
fontcolor = config.Internal.last_colors[0]
bgcolor = config.Internal.last_colors[1]
ocolor = config.Internal.last_colors[2]
config.Internal.last_colors = [fontcolor, bgcolor, ocolor]
config.Internal.last_words = line
min_x = data["min_x"]
min_y = data["min_y"]
max_x = data["max_x"]
max_y = data["max_y"]
min_x_p = min_x - config.padding
min_y_p = min_y - config.padding + data["ascender"]
max_x_p = max_x + config.padding
max_y_p = max_y + config.padding
if not config.descender:
max_y_p -= data["descender"]
if config.bgcolor:
alpha = utils.add_alpha(bgcolor, config.opacity)
rect_pos = (min_x_p, min_y_p), (max_x_p, max_y_p)
draw.rounded_rectangle(rect_pos, fill=alpha, radius=config.radius)
if config.outline:
owidth = config.outlinewidth
owidth = utils.divisible(owidth, 2)
halfwidth = owidth / 2
if not config.no_outline_top:
draw.line([(min_x_p, min_y_p - halfwidth),
(max_x_p, min_y_p - halfwidth)], fill=ocolor, width=owidth)
if not config.no_outline_left:
draw.line([(min_x_p - halfwidth, min_y_p - owidth + 1),
(min_x_p - halfwidth, max_y_p + owidth)], fill=ocolor, width=owidth)
if not config.no_outline_bottom:
draw.line([(min_x_p, max_y_p + halfwidth),
(max_x_p, max_y_p + halfwidth)], fill=ocolor, width=owidth)
if not config.no_outline_right:
draw.line([(max_x_p + halfwidth, min_y_p - owidth + 1),
(max_x_p + halfwidth, max_y_p + owidth)], fill=ocolor, width=owidth)
draw.multiline_text((min_x, min_y), line, fill=fontcolor, font=font, align=config.align)
return frame
def get_text_data(frame: Image.Image, line: str, font: ImageFont.FreeTypeFont) -> Dict[str, int]:
draw = ImageDraw.Draw(frame)
width, height = frame.size
p_top = config.top
p_bottom = config.bottom
p_left = config.left
p_right = config.right
b_left, b_top, b_right, b_bottom = draw.multiline_textbbox((0, 0), line, font=font)
ascender = font.getbbox(line.split("\n")[0])[1]
descender = font.getbbox(line.split("\n")[-1], anchor="ls")[3]
# Left
if (p_left is not None) and (p_left >= 0):
text_x = p_left
# Right
elif (p_right is not None) and (p_right >= 0):
text_x = width - b_right - p_right
else:
# Center Horizontal
text_x = (width - b_right) // 2
# Negatives Horizontal
if (p_left is not None) and (p_left < 0):
text_x += p_left
elif (p_right is not None) and (p_right < 0):
text_x -= p_right
# Top
if (p_top is not None) and (p_top >= 0):
text_y = p_top - ascender
# Bottom
elif (p_bottom is not None) and (p_bottom >= 0):
if not config.descender:
text_y = height - b_bottom + descender - p_bottom
else:
text_y = height - b_bottom - p_bottom
else:
# Center Vertical
if not config.descender:
text_y = (height - b_bottom + descender - ascender - (config.padding / 2)) // 2
else:
text_y = (height - b_bottom - ascender) // 2
# Negatives Vertical
if (p_top is not None) and (p_top < 0):
text_y += p_top
elif (p_bottom is not None) and (p_bottom < 0):
text_y -= p_bottom
ans = {
"min_x": text_x,
"min_y": text_y,
"max_x": text_x + b_right,
"max_y": text_y + b_bottom,
"ascender": ascender,
"descender": descender,
}
return ans
def word_frames(frames: List[Image.Image]) -> List[Image.Image]:
if not config.words:
return frames
worded = []
num_words = len(config.words)
for i, frame in enumerate(frames):
if config.fillgen:
line = words.generate(config.words[0], False)[0]
else:
index = i
if index >= num_words:
if config.fillwords:
index = num_words - 1
else:
worded.append(frame)
continue
line = config.words[index]
if line:
frame = draw_text(frame, line)
worded.append(frame)
return worded
def resize_frames(frames: List[Image.Image]) -> List[Image.Image]:
if (not config.width) and (not config.height):
return frames
new_frames = []
new_width = config.width
new_height = config.height
w, h = frames[0].size
ratio = w / h
if new_width and (not new_height):
new_height = int(new_width / ratio)
elif new_height and (not new_width):
new_width = int(new_height * ratio)
assert isinstance(new_width, int)
assert isinstance(new_height, int)
if (new_width <= 0) or (new_height <= 0):
return frames
if config.nogrow:
if (new_width > w) or (new_height > h):
return frames
size = (new_width, new_height)
for frame in frames:
new_frames.append(frame.resize(size))
return new_frames
def render(frames: List[Image.Image]) -> Union[Path, None]:
assert isinstance(config.output, Path)
ext = utils.get_extension(config.output)
fmt = ext if ext else config.format
if config.vertical or config.horizontal:
if fmt not in ["jpg", "png"]:
fmt = "png"
def makedir(path: Path) -> None:
try:
path.mkdir(parents=False, exist_ok=True)
except BaseException:
utils.exit("Failed to make output directory")
return
if ext:
makedir(config.output.parent)
output = config.output
else:
makedir(config.output)
rand = utils.random_string()
file_name = f"{rand}.{config.format}"
output = Path(config.output, file_name)
if config.vertical:
frames = [append_frames(frames, "vertical")]
if config.horizontal:
frames = [append_frames(frames, "horizontal")]
if fmt == "gif":
frames = to_array_all(frames)
loop = None if config.loop <= -1 else config.loop
imageio.mimsave(output, frames, format="GIF", duration=config.delay, loop=loop)
elif fmt == "png":
frame = frames[0]
frame = to_array(frame)
imageio.imsave(output, frame, format="PNG")
elif fmt == "jpg":
frame = frames[0]
frame = frame.convert("RGB")
frame = to_array(frame)
imageio.imsave(output, frame, format="JPEG")
elif fmt == "mp4" or fmt == "webm":
frames = to_array_all(frames)
fps = 1000 / config.delay
if fmt == "mp4":
codec = "libx264"
elif fmt == "webm":
codec = "libvpx"
writer = imageio.get_writer(output, fps=fps, codec=codec)
for frame in frames:
writer.append_data(frame)
writer.close()
else:
utils.exit("Invalid format")
return None
return output
def apply_filters(frames: List[Image.Image]) -> List[Image.Image]:
if (config.filter == "none") and (not config.filterlist):
return frames
new_frames = []
min_hue = 1
max_hue = 8
hue_step = 20
hue_filters = [f"hue{i}" for i in range(min_hue, max_hue + 1)]
all_filters = hue_filters + ["gray", "blur", "invert", "none"]
filters = []
def get_filters() -> None:
nonlocal filters
if config.filteropts:
filters = config.filteropts.copy()
elif config.filter.startswith("anyhue"):
filters = hue_filters.copy()
else:
filters = all_filters.copy()
def random_filter() -> str:
assert isinstance(config.Internal.random_filters, random.Random)
filtr = config.Internal.random_filters.choice(filters)
if not config.repeatfilter:
remove_filter(filtr)
return filtr
def remove_filter(filtr: str) -> None:
if filtr in filters:
filters.remove(filtr)
if not filters:
get_filters()
def change_hue(frame: Image.Image, n: int) -> Image.Image:
hsv = frame.convert("HSV")
h, s, v = hsv.split()
h = h.point(lambda i: (i + hue_step * n) % 180)
new_frame = Image.merge("HSV", (h, s, v))
if frame.mode in ["RGBA", "LA"]:
new_frame = Image.merge("RGBA", (new_frame.split() + (frame.split()[3],)))
else:
new_frame = new_frame.convert("RGB")
return new_frame
get_filters()
filtr = config.filter
if not config.filterlist:
if config.filter == "random" or config.filter == "anyhue":
filtr = random_filter()
for frame in frames:
if config.filterlist:
filtr = config.filterlist.pop(0)
elif config.filter == "random2" or config.filter == "anyhue2":
filtr = random_filter()
new_frame = None
if filtr.startswith("hue"):
for n in range(min_hue, max_hue + 1):
if filtr == f"hue{n}":
new_frame = change_hue(frame, n)
break
if new_frame is None:
if filtr in ["gray", "grey"]:
if frame.mode in ["RGBA", "LA"]:
r, g, b, a = frame.split()
gray_img = ImageOps.grayscale(frame.convert("RGB"))
rgb_gray = ImageOps.colorize(gray_img, "black", "white")
new_frame = Image.merge("RGBA", (rgb_gray.split() + (a,)))
else:
new_frame = ImageOps.colorize(frame.convert("L"), "black", "white")
elif filtr == "blur":
new_frame = frame.filter(ImageFilter.BLUR)
elif filtr == "invert":
new_frame = ImageOps.invert(frame.convert("RGB"))
else:
new_frame = frame
new_frames.append(new_frame)
return new_frames
def count_frames() -> None:
if config.frames is not None:
return
if config.framelist:
config.frames = len(config.framelist)
elif config.words:
num_words = len(config.words)
config.frames = num_words if num_words > 0 else config.frames
else:
config.frames = 3
def rgb_or_rgba(array: npt.NDArray[np.float64]) -> str:
if array.shape[2] == 4:
return "RGBA"
else:
return "RGB"
def to_pillow(array: npt.NDArray[np.float64]) -> Image.Image:
mode = rgb_or_rgba(array)
return Image.fromarray(array, mode=mode)
def to_array(frame: Image.Image) -> npt.NDArray[np.float64]:
return np.array(frame)
def to_array_all(frames: List[Image.Image]) -> List[npt.NDArray[np.float64]]:
return [to_array(frame) for frame in frames]
def deep_fry(frames: List[Image.Image]) -> List[Image.Image]:
if not config.deepfry:
return frames
quality = 3
new_frames = []
for frame in frames:
stream = BytesIO()
frame = frame.convert("RGB")
frame.save(stream, format="JPEG", quality=quality)
new_frames.append(Image.open(stream))
return new_frames
def append_frames(frames: List[Image.Image], mode: str) -> Image.Image:
widths, heights = zip(*(i.size for i in frames))
if mode == "vertical":
total_width = max(widths)
total_height = sum(heights)
elif mode == "horizontal":
total_width = sum(widths)
total_height = max(heights)
new_frame = Image.new("RGB", (total_width, total_height))
offset = 0
for frame in frames:
if mode == "vertical":
new_frame.paste(frame, (0, offset))
offset += frame.size[1]
elif mode == "horizontal":
new_frame.paste(frame, (offset, 0))
offset += frame.size[0]
return new_frame

20000
gifmaker/nouns.txt Normal file

File diff suppressed because it is too large Load Diff

148
gifmaker/utils.py Normal file
View File

@ -0,0 +1,148 @@
# Standard
import re
import sys
import random
import string
import colorsys
from pathlib import Path
from datetime import datetime
from typing import Dict, Union, Tuple
# Libraries
import webcolors # type: ignore
def random_string() -> str:
vowels = "aeiou"
consonants = "".join(set(string.ascii_lowercase) - set(vowels))
def con() -> str:
return random.choice(consonants)
def vow() -> str:
return random.choice(vowels)
return con() + vow() + con() + vow() + con() + vow()
def get_extension(path: Path) -> str:
return Path(path).suffix.lower().lstrip(".")
def exit(message: str) -> None:
msg(f"\nExit: {message}\n")
sys.exit(1)
def read_toml(path: Path) -> Union[Dict[str, str], None]:
import tomllib
if (not path.exists()) or (not path.is_file()):
exit("TOML file does not exist")
return None
try:
return tomllib.load(open(path, "rb"))
except Exception as e:
msg(f"Error: {e}")
exit("Failed to read TOML file")
return None
def random_color() -> Tuple[int, int, int]:
from .config import config
assert isinstance(config.Internal.random_colors, random.Random)
def component():
return config.Internal.random_colors.randint(0, 255)
return component(), component(), component()
def random_light() -> Tuple[int, int, int]:
color = random_color()
return change_lightness(color, 255 - 20)
def random_dark() -> Tuple[int, int, int]:
color = random_color()
return change_lightness(color, 40)
def change_lightness(color: Tuple[int, int, int], lightness: int) -> Tuple[int, int, int]:
hsv = list(colorsys.rgb_to_hsv(*color))
hsv[2] = lightness
rgb = colorsys.hsv_to_rgb(*hsv)
return (int(rgb[0]), int(rgb[1]), int(rgb[2]))
def light_contrast(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
return change_lightness(color, 200)
def dark_contrast(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
return change_lightness(color, 55)
def random_digit(allow_zero: bool) -> int:
if allow_zero:
return random.randint(0, 9)
else:
return random.randint(1, 9)
def get_date(fmt: str) -> str:
if fmt:
return datetime.now().strftime(fmt)
else:
return str(int(datetime.now().timestamp()))
def add_alpha(rgb: Tuple[int, int, int], alpha: float) -> Tuple[int, int, int, int]:
return int(rgb[0]), int(rgb[1]), int(rgb[2]), int(255 * alpha)
def color_name(name: str) -> Union[Tuple[int, int, int], None]:
try:
return tuple(webcolors.name_to_rgb(name))
except BaseException:
return None
def clean_lines(s: str) -> str:
cleaned = s
cleaned = re.sub(r" *(\n|\\n) *", "\n", cleaned)
cleaned = re.sub(r" +", " ", cleaned)
return cleaned.strip()
def divisible(number: int, by: int) -> int:
while number % by != 0:
number += 1
return number
def msg(message: str) -> None:
print(message, file=sys.stderr)
def respond(message: str) -> None:
print(message, file=sys.stdout)
def colortext(color: str, text: str) -> str:
codes = {
"red": "\x1b[31m",
"green": "\x1b[32m",
"yellow": "\x1b[33m",
"blue": "\x1b[34m",
"magenta": "\x1b[35m",
"cyan": "\x1b[36m",
}
if color in codes:
code = codes[color]
text = f"{code}{text}\x1b[0m"
return text

195
gifmaker/words.py Normal file
View File

@ -0,0 +1,195 @@
# Modules
from .config import config
from . import utils
# Standard
import re
import random
from typing import List, Any
from pathlib import Path
def process_words() -> None:
if config.remake or config.fillgen:
return
check_empty()
check_generators()
check_repeat()
def check_generators() -> None:
if not config.words:
return
new_lines: List[str] = []
for line in config.words:
new_lines.extend(generate(line))
config.words = new_lines
def generate(line: str, multiple: bool = True) -> List[str]:
def randgen(word: str, num: int) -> List[str]:
items: List[str] = []
for _ in range(num):
allow_zero = True
if num > 1:
if len(items) == 0:
allow_zero = False
items.append(get_random(word, allow_zero))
return items
def replace_random(match: re.Match[Any]) -> str:
num = None
if match["number"]:
num = int(match["number"])
if (num is None) or (num < 1):
num = 1
return " ".join(randgen(match["word"], num))
def replace_number(match: re.Match[Any]) -> str:
num1 = None
num2 = None
if match["number1"]:
num1 = int(match["number1"])
if match["number2"]:
num2 = int(match["number2"])
if (num1 is not None) and (num2 is not None):
if num1 >= num2:
return ""
assert isinstance(config.Internal.random_words, random.Random)
return str(config.Internal.random_words.randint(num1, num2))
if (num1 is None) or (num1 < 1):
num1 = 1
return "".join(randgen("number", num1))
def replace_count(match: re.Match[Any]) -> str:
config.Internal.wordcount += 1
return str(config.Internal.wordcount)
def replace_date(match: re.Match[Any]) -> str:
fmt = match["format"] or "%H:%M:%S"
return utils.get_date(fmt)
multi = 1
new_lines: List[str] = []
pattern_multi = re.compile(r"\[\s*(?:x(?P<number>\d+))?\s*\]$", re.IGNORECASE)
if multiple:
match_multi = re.search(pattern_multi, line)
if match_multi:
multi = max(1, int(match_multi["number"]))
line = re.sub(pattern_multi, "", line).strip()
pattern_random = re.compile(r"\[\s*(?P<word>randomx?)(?:\s+(?P<number>\d+))?\s*\]", re.IGNORECASE)
pattern_number = re.compile(r"\[\s*(?P<word>number)(?:\s+(?P<number1>-?\d+)(?:\s+(?P<number2>-?\d+))?)?\s*\]", re.IGNORECASE)
pattern_count = re.compile(r"\[(?P<word>count)\]", re.IGNORECASE)
pattern_date = re.compile(r"\[\s*(?P<word>date)(?:\s+(?P<format>.*))?\s*\]", re.IGNORECASE)
for _ in range(multi):
new_line = line
new_line = re.sub(pattern_random, replace_random, new_line)
new_line = re.sub(pattern_number, replace_number, new_line)
new_line = re.sub(pattern_count, replace_count, new_line)
new_line = re.sub(pattern_date, replace_date, new_line)
new_lines.append(new_line)
return new_lines
def check_repeat() -> None:
if not config.words:
return
new_lines: List[str] = []
pattern = re.compile(
r"^\[\s*(?P<word>rep(?:eat)?)\s*(?P<number>\d+)?\s*\]$", re.IGNORECASE)
for line in config.words:
match = re.match(pattern, line)
if match:
num = match["number"]
number = int(num) if num is not None else 1
new_lines.extend([new_lines[-1]] * number)
else:
new_lines.append(line)
config.words = new_lines
def check_empty() -> None:
if not config.words:
return
new_lines: List[str] = []
pattern = re.compile(
r"^\[\s*(?P<word>empty)(?:\s+(?P<number>\d+))?\s*\]$", re.IGNORECASE)
for line in config.words:
match = re.match(pattern, line)
if match:
num = match["number"]
number = int(num) if num is not None else 1
for _ in range(number):
new_lines.append("")
else:
new_lines.append(line)
config.words = new_lines
def random_word() -> str:
if not config.randomlist:
assert isinstance(config.randomfile, Path)
lines = config.randomfile.read_text().splitlines()
config.randomlist = [line.strip() for line in lines]
if not config.Internal.randwords:
config.Internal.randwords = config.randomlist.copy()
if not config.Internal.randwords:
return ""
assert isinstance(config.Internal.random_words, random.Random)
w = config.Internal.random_words.choice(config.Internal.randwords)
if not config.repeatrandom:
config.Internal.randwords.remove(w)
return w
def get_random(rand: str, allow_zero: bool) -> str:
if rand == "random":
return random_word().lower()
elif rand == "RANDOM":
return random_word().upper()
elif rand == "Random":
return random_word().capitalize()
elif rand == "RanDom":
return random_word().title()
elif rand == "randomx":
return random_word()
elif rand == "number":
return str(utils.random_digit(allow_zero))
else:
return ""

BIN
media/arguments.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
media/image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

BIN
media/installation.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
media/mean.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
media/more.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
media/usage.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
media/video.webm Normal file

Binary file not shown.

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
imageio ~= 2.34.0
imageio-ffmpeg ~= 0.4.9
pillow ~= 10.2.0
numpy ~= 1.26.4
webcolors ~= 1.13

4
run.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
root="$(dirname "$(readlink -f "$0")")"
cd "$root"
venv/bin/python -m gifmaker.main "$@"

4
scripts/format.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
root="$(dirname "$(readlink -f "$0")")"
parent="$(dirname "$root")"
autopep8 --in-place --recursive --aggressive --max-line-length=140 "$parent/gifmaker"

20
scripts/tag.py Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python
# This is used to create a tag in the git repo
# You probably don't want to run this
# pacman: python-gitpython
import os
import git
import time
from pathlib import Path
here = Path(__file__).resolve()
parent = here.parent.parent
os.chdir(parent)
name = int(time.time())
repo = git.Repo(".")
repo.create_tag(name)
repo.remotes.origin.push(name)
print(f"Created tag: {name}")

8
scripts/test.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
# This is used to test if the program is working properly
root="$(dirname "$(readlink -f "$0")")"
parent="$(dirname "$root")"
cd "$parent"
venv/bin/python -m gifmaker.main --script "scripts/test.toml"

13
scripts/test.toml Normal file
View File

@ -0,0 +1,13 @@
input = "media/video.webm"
output = "/tmp/gifmaker_test.gif"
words = "Disregard [Random] ; Acquire [Random] ; [date] ; [number 0 999];"
fontsize = 80
fontcolor = "light2"
bgcolor = "darkfont2"
outline = "font"
filter = "random2"
width = 700
bottom = 30
right = 30
delay = "p100"
verbose = true

9
scripts/venv.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# This is used to install the python virtual env
root="$(dirname "$(readlink -f "$0")")"
parent="$(dirname "$root")"
cd "$parent"
python -m venv venv &&
venv/bin/pip install -r requirements.txt

29
setup.py Normal file
View File

@ -0,0 +1,29 @@
from setuptools import setup, find_packages
import json
with open("gifmaker/manifest.json", "r") as file:
manifest = json.load(file)
title = manifest["title"]
program = manifest["program"]
version = manifest["version"]
with open("requirements.txt") as f:
requirements = f.read().splitlines()
package_data = {}
package_data[program] = ["fonts/*.ttf", "*.txt", "*.json"]
setup(
name = title,
version = version,
install_requires=requirements,
packages = find_packages(where="."),
package_dir = {"": "."},
package_data = package_data,
entry_points = {
"console_scripts": [
f"{program}={program}.main:main",
],
},
)