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