first commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
venv/*
|
||||||
|
output/*
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.mypy_cache/
|
||||||
|
Gifmaker.egg-info/
|
||||||
|
build/
|
972
README.md
Normal file
972
README.md
Normal 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
0
gifmaker/__init__.py
Normal file
164
gifmaker/argparser.py
Normal file
164
gifmaker/argparser.py
Normal 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
457
gifmaker/config.py
Normal 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()
|
BIN
gifmaker/fonts/ComicNeue-Regular.ttf
Normal file
BIN
gifmaker/fonts/ComicNeue-Regular.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/NovaSquare-Regular.ttf
Normal file
BIN
gifmaker/fonts/NovaSquare-Regular.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/Pacifico-Regular.ttf
Normal file
BIN
gifmaker/fonts/Pacifico-Regular.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/Roboto-Bold.ttf
Normal file
BIN
gifmaker/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/Roboto-Italic.ttf
Normal file
BIN
gifmaker/fonts/Roboto-Italic.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/Roboto-Regular.ttf
Normal file
BIN
gifmaker/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/RobotoMono-Regular.ttf
Normal file
BIN
gifmaker/fonts/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
BIN
gifmaker/fonts/RobotoSerif-Regular.ttf
Normal file
BIN
gifmaker/fonts/RobotoSerif-Regular.ttf
Normal file
Binary file not shown.
102
gifmaker/main.py
Normal file
102
gifmaker/main.py
Normal 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
7
gifmaker/manifest.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"title": "Gifmaker",
|
||||||
|
"program": "gifmaker",
|
||||||
|
"license": "Custom",
|
||||||
|
"author": "madprops"
|
||||||
|
}
|
511
gifmaker/media.py
Normal file
511
gifmaker/media.py
Normal 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
20000
gifmaker/nouns.txt
Normal file
File diff suppressed because it is too large
Load Diff
148
gifmaker/utils.py
Normal file
148
gifmaker/utils.py
Normal 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
195
gifmaker/words.py
Normal 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
BIN
media/arguments.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
BIN
media/image.jpg
Normal file
BIN
media/image.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 215 KiB |
BIN
media/installation.gif
Normal file
BIN
media/installation.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
BIN
media/mean.gif
Normal file
BIN
media/mean.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 422 KiB |
BIN
media/more.gif
Normal file
BIN
media/more.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 169 KiB |
BIN
media/usage.gif
Normal file
BIN
media/usage.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
BIN
media/video.webm
Normal file
BIN
media/video.webm
Normal file
Binary file not shown.
5
requirements.txt
Normal file
5
requirements.txt
Normal 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
4
run.sh
Executable 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
4
scripts/format.sh
Executable 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
20
scripts/tag.py
Executable 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
8
scripts/test.sh
Executable 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
13
scripts/test.toml
Normal 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
9
scripts/venv.sh
Executable 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
29
setup.py
Normal 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
Reference in New Issue
Block a user