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