tv-static-gif 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tv_static_gif-0.2.0/.gitattributes +2 -0
- tv_static_gif-0.2.0/.github/workflows/release.yml +41 -0
- tv_static_gif-0.2.0/.github/workflows/tests.yml +14 -0
- tv_static_gif-0.2.0/.gitignore +12 -0
- tv_static_gif-0.2.0/CHANGELOG.md +8 -0
- tv_static_gif-0.2.0/LICENSE +22 -0
- tv_static_gif-0.2.0/PKG-INFO +317 -0
- tv_static_gif-0.2.0/README.md +290 -0
- tv_static_gif-0.2.0/assets/examples/brand-mask.png +0 -0
- tv_static_gif-0.2.0/assets/examples/crt-text.gif +0 -0
- tv_static_gif-0.2.0/assets/examples/weak-signal-logo.gif +0 -0
- tv_static_gif-0.2.0/pyproject.toml +42 -0
- tv_static_gif-0.2.0/release.yml +41 -0
- tv_static_gif-0.2.0/src/tv_static_gif/__init__.py +6 -0
- tv_static_gif-0.2.0/src/tv_static_gif/cli.py +34 -0
- tv_static_gif-0.2.0/src/tv_static_gif/generator.py +192 -0
- tv_static_gif-0.2.0/tests/test_generator.py +29 -0
- tv_static_gif-0.2.0/tv.webp +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build distributions
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- run: python -m pip install --upgrade build twine
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- run: python -m twine check dist/*
|
|
22
|
+
- uses: actions/upload-artifact@v4
|
|
23
|
+
with:
|
|
24
|
+
name: python-package-distributions
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish-to-pypi:
|
|
28
|
+
name: Publish Python distribution to PyPI
|
|
29
|
+
needs: build
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
environment:
|
|
32
|
+
name: pypi
|
|
33
|
+
url: https://pypi.org/project/tv-static-gif/
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/download-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: python-package-distributions
|
|
40
|
+
path: dist/
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-python@v5
|
|
11
|
+
with: { python-version: "3.12" }
|
|
12
|
+
- run: python -m pip install -e ".[dev]"
|
|
13
|
+
- run: python -m pytest -q
|
|
14
|
+
- run: python -m build
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anderson Fabio Landim Carvalho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tv-static-gif
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Generate animated TV-static GIFs with Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Anderson-Landim/tv-static-gif
|
|
6
|
+
Project-URL: Issues, https://github.com/Anderson-Landim/tv-static-gif/issues
|
|
7
|
+
Author: Your Name
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: animation,gif,noise,static,television
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: pillow>=10.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tv-static-gif
|
|
29
|
+
|
|
30
|
+
Biblioteca Python e ferramenta de terminal para criar animações de chiado de TV. Ela gera GIF, WebP animado ou APNG com fundo, texto e logo/imagem em chiado independente.
|
|
31
|
+
|
|
32
|
+
Por padrão, a animação tem 5 segundos, 820×740 px e 24 fps.
|
|
33
|
+
|
|
34
|
+
## Instalação
|
|
35
|
+
|
|
36
|
+
Quando o pacote estiver publicado:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install tv-static-gif
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Para desenvolver neste repositório:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install -e ".[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Verifique a instalação:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
tv-static-gif --version
|
|
52
|
+
tv-static-gif --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Início rápido
|
|
56
|
+
|
|
57
|
+
Crie um GIF simples:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
tv-static-gif tv.gif
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Crie um GIF com texto. O fundo troca a 24 fps e o chiado dentro das letras troca a 6 fps:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
tv-static-gif tv.gif --text "TV" --font-size 280 --background-fps 24 --text-fps 6
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
O mesmo exemplo em Python:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from tv_static_gif import generate_tv_static
|
|
73
|
+
|
|
74
|
+
generate_tv_static(
|
|
75
|
+
"tv.gif",
|
|
76
|
+
text="TV",
|
|
77
|
+
font_size=280,
|
|
78
|
+
background_fps=24,
|
|
79
|
+
text_fps=6,
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Conceitos principais
|
|
84
|
+
|
|
85
|
+
`fps` é a taxa de reprodução do arquivo. `background_fps`, `text_fps` e `image_fps` controlam com que frequência cada chiado é trocado e devem ser menores ou iguais a `fps`.
|
|
86
|
+
|
|
87
|
+
Texto e imagens não recebem uma cor sólida: eles usam o mesmo chiado cinza de pixels 1×1 do fundo, só com uma velocidade independente. Isso permite que as letras apareçam pelo movimento diferente da estática.
|
|
88
|
+
|
|
89
|
+
## Exemplos visuais
|
|
90
|
+
|
|
91
|
+
### CRT com texto lento
|
|
92
|
+
|
|
93
|
+
Fundo a 12 fps e texto a 3 fps, com linhas de varredura do preset `crt`.
|
|
94
|
+
|
|
95
|
+

|
|
96
|
+
|
|
97
|
+
### Sinal fraco com marca
|
|
98
|
+
|
|
99
|
+
O preset `weak-signal` combina tremor, cintilação e interferência. A marca no canto inferior usa uma máscara PNG e seu próprio ritmo de chiado.
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
103
|
+
## Linha de comando
|
|
104
|
+
|
|
105
|
+
Formato geral:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
tv-static-gif SAIDA [opções]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Todos os parâmetros
|
|
112
|
+
|
|
113
|
+
| Opção | Padrão | Descrição |
|
|
114
|
+
| ------------------------------ | ---------------------- | ---------------------------------------------------------------- |
|
|
115
|
+
| `output` | obrigatório | Caminho do arquivo de saída. |
|
|
116
|
+
| `--version` | — | Mostra a versão instalada. |
|
|
117
|
+
| `--width N` | `820` | Largura original em pixels. |
|
|
118
|
+
| `--height N` | `740` | Altura original em pixels. |
|
|
119
|
+
| `--duration N` | `5` | Duração em segundos. |
|
|
120
|
+
| `--fps N` | `24` | FPS de reprodução; de 1 a 100. |
|
|
121
|
+
| `--background-fps N` | igual a`--fps` | Velocidade de troca do fundo. |
|
|
122
|
+
| `--text TEXTO` | — | Texto renderizado como chiado. |
|
|
123
|
+
| `--text-fps N` | `6` | Velocidade de troca do chiado no texto. |
|
|
124
|
+
| `--font ARQUIVO` | fonte do sistema | Fonte`.ttf` ou `.otf`. |
|
|
125
|
+
| `--font-size N` | `220` | Tamanho da fonte em pixels. |
|
|
126
|
+
| `--text-position POSIÇÃO` | `center` | `top`, `center` ou `bottom`. |
|
|
127
|
+
| `--no-auto-fit` | desligado | Não reduz a fonte automaticamente quando o texto não cabe. |
|
|
128
|
+
| `--image ARQUIVO` | — | PNG/imagem com transparência usada como máscara de chiado. |
|
|
129
|
+
| `--image-fps N` | igual a`--text-fps` | Velocidade do chiado da imagem. |
|
|
130
|
+
| `--image-position POSIÇÃO` | `bottom` | `top`, `center` ou `bottom`. |
|
|
131
|
+
| `--image-scale N` | `0.25` | Fração máxima do quadro usada pela imagem. |
|
|
132
|
+
| `--preset NOME` | `crt` | `clean`, `crt`, `weak-signal` ou `vhs`. |
|
|
133
|
+
| `--quality NÍVEL` | `high` | `low`, `medium` ou `high`. Define escala e cores. |
|
|
134
|
+
| `--scale N` | definido por qualidade | Escala final do quadro;`0.5` produz metade da largura/altura. |
|
|
135
|
+
| `--colors N` | definido por qualidade | Número de tons de cinza, de 2 a 256. |
|
|
136
|
+
| `--format FORMATO` | pela extensão | `gif`, `webp` ou `apng`. |
|
|
137
|
+
| `--loop N` | `0` | Repetições;`0` significa repetir infinitamente. |
|
|
138
|
+
| `--output-dir PASTA` | — | Pasta de destino; preserva apenas o nome informado em`output`. |
|
|
139
|
+
| `--seed N` | aleatória | Semente para reproduzir a mesma animação. |
|
|
140
|
+
| `--preview` | desligado | Abre o arquivo quando a geração termina. |
|
|
141
|
+
|
|
142
|
+
### Exemplos de terminal
|
|
143
|
+
|
|
144
|
+
GIF de 10 segundos em resolução personalizada:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
tv-static-gif chiado.gif --width 1280 --height 720 --duration 10 --fps 30
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Fundo veloz e texto lento:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
tv-static-gif tv.gif --text "SEM SINAL" --fps 24 --background-fps 24 --text-fps 4
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Texto embaixo, com fonte escolhida:
|
|
157
|
+
|
|
158
|
+
```powershell
|
|
159
|
+
tv-static-gif tv.gif --text "AO VIVO" --font "C:/Windows/Fonts/arialbd.ttf" --font-size 150 --text-position bottom
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Logo PNG no topo, com velocidade separada:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
tv-static-gif tv.gif --image logo.png --image-position top --image-scale 0.2 --image-fps 8
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Efeito de sinal fraco:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
tv-static-gif sinal.gif --preset weak-signal --text "SEM SINAL" --text-fps 3
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Efeito VHS e arquivo menor:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
tv-static-gif fita.webp --format webp --preset vhs --quality medium --text "VHS"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
APNG com poucos tons de cinza:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
tv-static-gif chiado.png --format apng --quality low --colors 32
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Metade da resolução, com duas repetições:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
tv-static-gif pequeno.gif --scale 0.5 --loop 2
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Salvar numa pasta e abrir após gerar:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
tv-static-gif tv.gif --output-dir ./gifs --preview
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Gerar a mesma animação novamente:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
tv-static-gif teste.gif --seed 42 --text "TESTE"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## API Python
|
|
205
|
+
|
|
206
|
+
Há duas funções públicas:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from tv_static_gif import generate_tv_static, generate_tv_static_gif
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `generate_tv_static(...)`: cria GIF, WebP ou APNG.
|
|
213
|
+
- `generate_tv_static_gif(...)`: compatível com versões antigas; sempre cria GIF.
|
|
214
|
+
|
|
215
|
+
### Assinatura completa
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
generate_tv_static(
|
|
219
|
+
output,
|
|
220
|
+
width=820, height=740, duration=5, fps=24,
|
|
221
|
+
background_fps=None,
|
|
222
|
+
text=None, text_fps=6, font_path=None, font_size=220,
|
|
223
|
+
text_position="center", auto_fit=True,
|
|
224
|
+
image_path=None, image_fps=None, image_position="bottom", image_scale=0.25,
|
|
225
|
+
preset="crt", quality="high", scale=None, colors=None,
|
|
226
|
+
output_format=None, loop=0, seed=None, progress=None,
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
| Parâmetro | Descrição |
|
|
231
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------- |
|
|
232
|
+
| `output` | Caminho do arquivo criado. A extensão pode definir o formato. |
|
|
233
|
+
| `width`, `height`, `duration`, `fps` | Dimensões, duração e taxa de reprodução. |
|
|
234
|
+
| `background_fps`, `text_fps`, `image_fps` | Velocidade de atualização do chiado em cada camada. |
|
|
235
|
+
| `text`, `font_path`, `font_size` | Conteúdo e estilo do texto. |
|
|
236
|
+
| `text_position`, `image_position` | `top`, `center` ou `bottom`. |
|
|
237
|
+
| `auto_fit` | Ajusta o texto horizontalmente para caber no quadro. |
|
|
238
|
+
| `image_path`, `image_scale` | Imagem usada como máscara e seu tamanho máximo proporcional. |
|
|
239
|
+
| `preset` | `clean`, `crt`, `weak-signal` ou `vhs`. |
|
|
240
|
+
| `quality` | `low` = 50%/64 tons; `medium` = 75%/128 tons; `high` = 100%/256 tons. |
|
|
241
|
+
| `scale`, `colors` | Sobrescrevem a escala e os tons definidos pela qualidade. |
|
|
242
|
+
| `output_format` | `gif`, `webp` ou `apng`; quando `None`, usa a extensão. |
|
|
243
|
+
| `loop` | Número de repetições;`0` é infinito. |
|
|
244
|
+
| `seed` | Inteiro para resultado reproduzível. |
|
|
245
|
+
| `progress` | Função opcional chamada como`progress(quadro_atual, total_de_quadros)`. |
|
|
246
|
+
|
|
247
|
+
### Exemplos em Python
|
|
248
|
+
|
|
249
|
+
GIF simples:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from tv_static_gif import generate_tv_static_gif
|
|
253
|
+
|
|
254
|
+
path = generate_tv_static_gif("chiado.gif")
|
|
255
|
+
print(path)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
WebP com todas as camadas:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from tv_static_gif import generate_tv_static
|
|
262
|
+
|
|
263
|
+
generate_tv_static(
|
|
264
|
+
"sinal.webp",
|
|
265
|
+
output_format="webp",
|
|
266
|
+
preset="vhs",
|
|
267
|
+
quality="medium",
|
|
268
|
+
text="AO VIVO",
|
|
269
|
+
text_position="top",
|
|
270
|
+
text_fps=5,
|
|
271
|
+
background_fps=24,
|
|
272
|
+
image_path="logo.png",
|
|
273
|
+
image_position="bottom",
|
|
274
|
+
image_fps=8,
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Usando progresso:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from tv_static_gif import generate_tv_static
|
|
282
|
+
|
|
283
|
+
def progresso(atual, total):
|
|
284
|
+
print(f"Quadro {atual}/{total}")
|
|
285
|
+
|
|
286
|
+
generate_tv_static("progresso.gif", text="TV", progress=progresso)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Presets
|
|
290
|
+
|
|
291
|
+
- `clean`: apenas ruído cinza, sem efeitos extras.
|
|
292
|
+
- `crt`: linhas de varredura, pequena oscilação e cintilação leve.
|
|
293
|
+
- `weak-signal`: cintilação, tremor e faixas de interferência mais fortes.
|
|
294
|
+
- `vhs`: efeito de fita com linhas, tremor e glitches horizontais.
|
|
295
|
+
|
|
296
|
+
## Tamanho de arquivo e desempenho
|
|
297
|
+
|
|
298
|
+
Ruído aleatório comprime pouco. GIFs de 820×740 px, 24 fps e 5 segundos podem ser grandes. Para reduzir o arquivo, prefira `--quality medium`, `--quality low`, `--scale 0.5`, menos duração ou WebP animado.
|
|
299
|
+
|
|
300
|
+
## Desenvolvimento, testes e PyPI
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
python -m pip install -e ".[dev]"
|
|
304
|
+
python -m pytest -q
|
|
305
|
+
python -m build
|
|
306
|
+
twine check dist/*
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
O workflow em `.github/workflows/tests.yml` executa testes e build no GitHub Actions. Antes de publicar, troque o autor e as URLs em `pyproject.toml`, reserve o nome no PyPI e use:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
twine upload dist/*
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Licença
|
|
316
|
+
|
|
317
|
+
MIT. Veja [LICENSE](LICENSE).
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# tv-static-gif
|
|
2
|
+
|
|
3
|
+
Biblioteca Python e ferramenta de terminal para criar animações de chiado de TV. Ela gera GIF, WebP animado ou APNG com fundo, texto e logo/imagem em chiado independente.
|
|
4
|
+
|
|
5
|
+
Por padrão, a animação tem 5 segundos, 820×740 px e 24 fps.
|
|
6
|
+
|
|
7
|
+
## Instalação
|
|
8
|
+
|
|
9
|
+
Quando o pacote estiver publicado:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install tv-static-gif
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Para desenvolver neste repositório:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m pip install -e ".[dev]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Verifique a instalação:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
tv-static-gif --version
|
|
25
|
+
tv-static-gif --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Início rápido
|
|
29
|
+
|
|
30
|
+
Crie um GIF simples:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
tv-static-gif tv.gif
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Crie um GIF com texto. O fundo troca a 24 fps e o chiado dentro das letras troca a 6 fps:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
tv-static-gif tv.gif --text "TV" --font-size 280 --background-fps 24 --text-fps 6
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
O mesmo exemplo em Python:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from tv_static_gif import generate_tv_static
|
|
46
|
+
|
|
47
|
+
generate_tv_static(
|
|
48
|
+
"tv.gif",
|
|
49
|
+
text="TV",
|
|
50
|
+
font_size=280,
|
|
51
|
+
background_fps=24,
|
|
52
|
+
text_fps=6,
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Conceitos principais
|
|
57
|
+
|
|
58
|
+
`fps` é a taxa de reprodução do arquivo. `background_fps`, `text_fps` e `image_fps` controlam com que frequência cada chiado é trocado e devem ser menores ou iguais a `fps`.
|
|
59
|
+
|
|
60
|
+
Texto e imagens não recebem uma cor sólida: eles usam o mesmo chiado cinza de pixels 1×1 do fundo, só com uma velocidade independente. Isso permite que as letras apareçam pelo movimento diferente da estática.
|
|
61
|
+
|
|
62
|
+
## Exemplos visuais
|
|
63
|
+
|
|
64
|
+
### CRT com texto lento
|
|
65
|
+
|
|
66
|
+
Fundo a 12 fps e texto a 3 fps, com linhas de varredura do preset `crt`.
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
### Sinal fraco com marca
|
|
71
|
+
|
|
72
|
+
O preset `weak-signal` combina tremor, cintilação e interferência. A marca no canto inferior usa uma máscara PNG e seu próprio ritmo de chiado.
|
|
73
|
+
|
|
74
|
+

|
|
75
|
+
|
|
76
|
+
## Linha de comando
|
|
77
|
+
|
|
78
|
+
Formato geral:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
tv-static-gif SAIDA [opções]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Todos os parâmetros
|
|
85
|
+
|
|
86
|
+
| Opção | Padrão | Descrição |
|
|
87
|
+
| ------------------------------ | ---------------------- | ---------------------------------------------------------------- |
|
|
88
|
+
| `output` | obrigatório | Caminho do arquivo de saída. |
|
|
89
|
+
| `--version` | — | Mostra a versão instalada. |
|
|
90
|
+
| `--width N` | `820` | Largura original em pixels. |
|
|
91
|
+
| `--height N` | `740` | Altura original em pixels. |
|
|
92
|
+
| `--duration N` | `5` | Duração em segundos. |
|
|
93
|
+
| `--fps N` | `24` | FPS de reprodução; de 1 a 100. |
|
|
94
|
+
| `--background-fps N` | igual a`--fps` | Velocidade de troca do fundo. |
|
|
95
|
+
| `--text TEXTO` | — | Texto renderizado como chiado. |
|
|
96
|
+
| `--text-fps N` | `6` | Velocidade de troca do chiado no texto. |
|
|
97
|
+
| `--font ARQUIVO` | fonte do sistema | Fonte`.ttf` ou `.otf`. |
|
|
98
|
+
| `--font-size N` | `220` | Tamanho da fonte em pixels. |
|
|
99
|
+
| `--text-position POSIÇÃO` | `center` | `top`, `center` ou `bottom`. |
|
|
100
|
+
| `--no-auto-fit` | desligado | Não reduz a fonte automaticamente quando o texto não cabe. |
|
|
101
|
+
| `--image ARQUIVO` | — | PNG/imagem com transparência usada como máscara de chiado. |
|
|
102
|
+
| `--image-fps N` | igual a`--text-fps` | Velocidade do chiado da imagem. |
|
|
103
|
+
| `--image-position POSIÇÃO` | `bottom` | `top`, `center` ou `bottom`. |
|
|
104
|
+
| `--image-scale N` | `0.25` | Fração máxima do quadro usada pela imagem. |
|
|
105
|
+
| `--preset NOME` | `crt` | `clean`, `crt`, `weak-signal` ou `vhs`. |
|
|
106
|
+
| `--quality NÍVEL` | `high` | `low`, `medium` ou `high`. Define escala e cores. |
|
|
107
|
+
| `--scale N` | definido por qualidade | Escala final do quadro;`0.5` produz metade da largura/altura. |
|
|
108
|
+
| `--colors N` | definido por qualidade | Número de tons de cinza, de 2 a 256. |
|
|
109
|
+
| `--format FORMATO` | pela extensão | `gif`, `webp` ou `apng`. |
|
|
110
|
+
| `--loop N` | `0` | Repetições;`0` significa repetir infinitamente. |
|
|
111
|
+
| `--output-dir PASTA` | — | Pasta de destino; preserva apenas o nome informado em`output`. |
|
|
112
|
+
| `--seed N` | aleatória | Semente para reproduzir a mesma animação. |
|
|
113
|
+
| `--preview` | desligado | Abre o arquivo quando a geração termina. |
|
|
114
|
+
|
|
115
|
+
### Exemplos de terminal
|
|
116
|
+
|
|
117
|
+
GIF de 10 segundos em resolução personalizada:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
tv-static-gif chiado.gif --width 1280 --height 720 --duration 10 --fps 30
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Fundo veloz e texto lento:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
tv-static-gif tv.gif --text "SEM SINAL" --fps 24 --background-fps 24 --text-fps 4
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Texto embaixo, com fonte escolhida:
|
|
130
|
+
|
|
131
|
+
```powershell
|
|
132
|
+
tv-static-gif tv.gif --text "AO VIVO" --font "C:/Windows/Fonts/arialbd.ttf" --font-size 150 --text-position bottom
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Logo PNG no topo, com velocidade separada:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
tv-static-gif tv.gif --image logo.png --image-position top --image-scale 0.2 --image-fps 8
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Efeito de sinal fraco:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
tv-static-gif sinal.gif --preset weak-signal --text "SEM SINAL" --text-fps 3
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Efeito VHS e arquivo menor:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
tv-static-gif fita.webp --format webp --preset vhs --quality medium --text "VHS"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
APNG com poucos tons de cinza:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
tv-static-gif chiado.png --format apng --quality low --colors 32
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Metade da resolução, com duas repetições:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
tv-static-gif pequeno.gif --scale 0.5 --loop 2
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Salvar numa pasta e abrir após gerar:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
tv-static-gif tv.gif --output-dir ./gifs --preview
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Gerar a mesma animação novamente:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
tv-static-gif teste.gif --seed 42 --text "TESTE"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## API Python
|
|
178
|
+
|
|
179
|
+
Há duas funções públicas:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from tv_static_gif import generate_tv_static, generate_tv_static_gif
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- `generate_tv_static(...)`: cria GIF, WebP ou APNG.
|
|
186
|
+
- `generate_tv_static_gif(...)`: compatível com versões antigas; sempre cria GIF.
|
|
187
|
+
|
|
188
|
+
### Assinatura completa
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
generate_tv_static(
|
|
192
|
+
output,
|
|
193
|
+
width=820, height=740, duration=5, fps=24,
|
|
194
|
+
background_fps=None,
|
|
195
|
+
text=None, text_fps=6, font_path=None, font_size=220,
|
|
196
|
+
text_position="center", auto_fit=True,
|
|
197
|
+
image_path=None, image_fps=None, image_position="bottom", image_scale=0.25,
|
|
198
|
+
preset="crt", quality="high", scale=None, colors=None,
|
|
199
|
+
output_format=None, loop=0, seed=None, progress=None,
|
|
200
|
+
)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Parâmetro | Descrição |
|
|
204
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------- |
|
|
205
|
+
| `output` | Caminho do arquivo criado. A extensão pode definir o formato. |
|
|
206
|
+
| `width`, `height`, `duration`, `fps` | Dimensões, duração e taxa de reprodução. |
|
|
207
|
+
| `background_fps`, `text_fps`, `image_fps` | Velocidade de atualização do chiado em cada camada. |
|
|
208
|
+
| `text`, `font_path`, `font_size` | Conteúdo e estilo do texto. |
|
|
209
|
+
| `text_position`, `image_position` | `top`, `center` ou `bottom`. |
|
|
210
|
+
| `auto_fit` | Ajusta o texto horizontalmente para caber no quadro. |
|
|
211
|
+
| `image_path`, `image_scale` | Imagem usada como máscara e seu tamanho máximo proporcional. |
|
|
212
|
+
| `preset` | `clean`, `crt`, `weak-signal` ou `vhs`. |
|
|
213
|
+
| `quality` | `low` = 50%/64 tons; `medium` = 75%/128 tons; `high` = 100%/256 tons. |
|
|
214
|
+
| `scale`, `colors` | Sobrescrevem a escala e os tons definidos pela qualidade. |
|
|
215
|
+
| `output_format` | `gif`, `webp` ou `apng`; quando `None`, usa a extensão. |
|
|
216
|
+
| `loop` | Número de repetições;`0` é infinito. |
|
|
217
|
+
| `seed` | Inteiro para resultado reproduzível. |
|
|
218
|
+
| `progress` | Função opcional chamada como`progress(quadro_atual, total_de_quadros)`. |
|
|
219
|
+
|
|
220
|
+
### Exemplos em Python
|
|
221
|
+
|
|
222
|
+
GIF simples:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from tv_static_gif import generate_tv_static_gif
|
|
226
|
+
|
|
227
|
+
path = generate_tv_static_gif("chiado.gif")
|
|
228
|
+
print(path)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
WebP com todas as camadas:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
from tv_static_gif import generate_tv_static
|
|
235
|
+
|
|
236
|
+
generate_tv_static(
|
|
237
|
+
"sinal.webp",
|
|
238
|
+
output_format="webp",
|
|
239
|
+
preset="vhs",
|
|
240
|
+
quality="medium",
|
|
241
|
+
text="AO VIVO",
|
|
242
|
+
text_position="top",
|
|
243
|
+
text_fps=5,
|
|
244
|
+
background_fps=24,
|
|
245
|
+
image_path="logo.png",
|
|
246
|
+
image_position="bottom",
|
|
247
|
+
image_fps=8,
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Usando progresso:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from tv_static_gif import generate_tv_static
|
|
255
|
+
|
|
256
|
+
def progresso(atual, total):
|
|
257
|
+
print(f"Quadro {atual}/{total}")
|
|
258
|
+
|
|
259
|
+
generate_tv_static("progresso.gif", text="TV", progress=progresso)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Presets
|
|
263
|
+
|
|
264
|
+
- `clean`: apenas ruído cinza, sem efeitos extras.
|
|
265
|
+
- `crt`: linhas de varredura, pequena oscilação e cintilação leve.
|
|
266
|
+
- `weak-signal`: cintilação, tremor e faixas de interferência mais fortes.
|
|
267
|
+
- `vhs`: efeito de fita com linhas, tremor e glitches horizontais.
|
|
268
|
+
|
|
269
|
+
## Tamanho de arquivo e desempenho
|
|
270
|
+
|
|
271
|
+
Ruído aleatório comprime pouco. GIFs de 820×740 px, 24 fps e 5 segundos podem ser grandes. Para reduzir o arquivo, prefira `--quality medium`, `--quality low`, `--scale 0.5`, menos duração ou WebP animado.
|
|
272
|
+
|
|
273
|
+
## Desenvolvimento, testes e PyPI
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
python -m pip install -e ".[dev]"
|
|
277
|
+
python -m pytest -q
|
|
278
|
+
python -m build
|
|
279
|
+
twine check dist/*
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
O workflow em `.github/workflows/tests.yml` executa testes e build no GitHub Actions. Antes de publicar, troque o autor e as URLs em `pyproject.toml`, reserve o nome no PyPI e use:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
twine upload dist/*
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## Licença
|
|
289
|
+
|
|
290
|
+
MIT. Veja [LICENSE](LICENSE).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tv-static-gif"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Generate animated TV-static GIFs with Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Your Name" }]
|
|
13
|
+
keywords = ["gif", "television", "static", "noise", "animation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["Pillow>=10.0"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["build>=1.2", "pytest>=8.0", "twine>=5.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/Anderson-Landim/tv-static-gif"
|
|
33
|
+
Issues = "https://github.com/Anderson-Landim/tv-static-gif/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
tv-static-gif = "tv_static_gif.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/tv_static_gif"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build distributions
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- run: python -m pip install --upgrade build twine
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- run: python -m twine check dist/*
|
|
22
|
+
- uses: actions/upload-artifact@v4
|
|
23
|
+
with:
|
|
24
|
+
name: python-package-distributions
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish-to-pypi:
|
|
28
|
+
name: Publish Python distribution to PyPI
|
|
29
|
+
needs: build
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
environment:
|
|
32
|
+
name: pypi
|
|
33
|
+
url: https://pypi.org/p/tv-static-gif
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/download-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: python-package-distributions
|
|
40
|
+
path: dist/
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .generator import PRESETS, QUALITY, generate_tv_static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Generate animated TV static.")
|
|
15
|
+
parser.add_argument("output")
|
|
16
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
17
|
+
parser.add_argument("--width", type=int, default=820); parser.add_argument("--height", type=int, default=740)
|
|
18
|
+
parser.add_argument("--duration", type=float, default=5); parser.add_argument("--fps", type=int, default=24)
|
|
19
|
+
parser.add_argument("--background-fps", type=int); parser.add_argument("--text", default=None); parser.add_argument("--text-fps", type=int, default=6)
|
|
20
|
+
parser.add_argument("--font"); parser.add_argument("--font-size", type=int, default=220); parser.add_argument("--text-position", choices=("top", "center", "bottom"), default="center")
|
|
21
|
+
parser.add_argument("--no-auto-fit", action="store_true"); parser.add_argument("--image"); parser.add_argument("--image-fps", type=int); parser.add_argument("--image-position", choices=("top", "center", "bottom"), default="bottom"); parser.add_argument("--image-scale", type=float, default=.25)
|
|
22
|
+
parser.add_argument("--preset", choices=tuple(PRESETS), default="crt"); parser.add_argument("--quality", choices=tuple(QUALITY), default="high"); parser.add_argument("--scale", type=float); parser.add_argument("--colors", type=int)
|
|
23
|
+
parser.add_argument("--format", dest="output_format", choices=("gif", "webp", "apng")); parser.add_argument("--loop", type=int, default=0); parser.add_argument("--output-dir", type=Path); parser.add_argument("--seed", type=int); parser.add_argument("--preview", action="store_true")
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
output = (args.output_dir / Path(args.output).name) if args.output_dir else Path(args.output)
|
|
26
|
+
params = vars(args); params.pop("output"); params.pop("output_dir"); preview = params.pop("preview"); params["font_path"] = params.pop("font"); params["image_path"] = params.pop("image"); params["auto_fit"] = not params.pop("no_auto_fit")
|
|
27
|
+
path = generate_tv_static(output, **params)
|
|
28
|
+
print(f"Created: {path}")
|
|
29
|
+
if preview:
|
|
30
|
+
if hasattr(os, "startfile"): os.startfile(path) # type: ignore[attr-defined]
|
|
31
|
+
else: webbrowser.open(path.resolve().as_uri())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Create animated TV-static files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|
10
|
+
|
|
11
|
+
PathLike = Union[str, Path]
|
|
12
|
+
ProgressCallback = Callable[[int, int], None]
|
|
13
|
+
|
|
14
|
+
PRESETS = {
|
|
15
|
+
"clean": {"scanlines": 0, "flicker": 0, "jitter": 0, "glitch": 0},
|
|
16
|
+
"crt": {"scanlines": 4, "flicker": 8, "jitter": 1, "glitch": 0},
|
|
17
|
+
"weak-signal": {"scanlines": 3, "flicker": 24, "jitter": 3, "glitch": 3},
|
|
18
|
+
"vhs": {"scanlines": 4, "flicker": 14, "jitter": 2, "glitch": 8},
|
|
19
|
+
}
|
|
20
|
+
QUALITY = {"low": (0.5, 64), "medium": (0.75, 128), "high": (1.0, 256)}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _font(path: Optional[PathLike], size: int) -> ImageFont.ImageFont:
|
|
24
|
+
if path is not None:
|
|
25
|
+
try:
|
|
26
|
+
return ImageFont.truetype(str(path), size)
|
|
27
|
+
except OSError as error:
|
|
28
|
+
raise ValueError(f"could not load font: {path}") from error
|
|
29
|
+
for name in ("DejaVuSans-Bold.ttf", "Arial.ttf", "arial.ttf"):
|
|
30
|
+
try:
|
|
31
|
+
return ImageFont.truetype(name, size)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
return ImageFont.load_default()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _position(size: tuple[int, int], content: tuple[int, int], where: str) -> tuple[int, int]:
|
|
38
|
+
width, height = size
|
|
39
|
+
content_width, content_height = content
|
|
40
|
+
x = (width - content_width) // 2
|
|
41
|
+
if where == "top":
|
|
42
|
+
return x, max(0, height // 20)
|
|
43
|
+
if where == "bottom":
|
|
44
|
+
return x, max(0, height - content_height - height // 20)
|
|
45
|
+
return x, (height - content_height) // 2
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _text_mask(
|
|
49
|
+
size: tuple[int, int], text: Optional[str], font_path: Optional[PathLike],
|
|
50
|
+
font_size: int, position: str, auto_fit: bool,
|
|
51
|
+
) -> Optional[Image.Image]:
|
|
52
|
+
if not text:
|
|
53
|
+
return None
|
|
54
|
+
font = _font(font_path, font_size)
|
|
55
|
+
probe = ImageDraw.Draw(Image.new("L", (1, 1)))
|
|
56
|
+
box = probe.multiline_textbbox((0, 0), text, font=font, align="center")
|
|
57
|
+
if auto_fit and box[2] - box[0] > size[0] * 0.9 and font_size > 8:
|
|
58
|
+
font = _font(font_path, max(8, int(font_size * size[0] * 0.9 / (box[2] - box[0]))))
|
|
59
|
+
box = probe.multiline_textbbox((0, 0), text, font=font, align="center")
|
|
60
|
+
mask = Image.new("L", size)
|
|
61
|
+
draw = ImageDraw.Draw(mask)
|
|
62
|
+
xy = _position(size, (box[2] - box[0], box[3] - box[1]), position)
|
|
63
|
+
draw.multiline_text((xy[0] - box[0], xy[1] - box[1]), text, font=font, fill=255, align="center")
|
|
64
|
+
return mask
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _image_mask(path: Optional[PathLike], size: tuple[int, int], position: str, scale: float) -> Optional[Image.Image]:
|
|
68
|
+
if path is None:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
source = Image.open(path).convert("RGBA")
|
|
72
|
+
except OSError as error:
|
|
73
|
+
raise ValueError(f"could not load image: {path}") from error
|
|
74
|
+
max_size = (max(1, int(size[0] * scale)), max(1, int(size[1] * scale)))
|
|
75
|
+
source.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
76
|
+
mask = Image.new("L", size)
|
|
77
|
+
mask.paste(source.getchannel("A"), _position(size, source.size, position))
|
|
78
|
+
return mask
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pattern(size: tuple[int, int], rng: random.Random, preset: dict[str, int], colors: int) -> Image.Image:
|
|
82
|
+
width, height = size
|
|
83
|
+
pixels = bytearray(rng.randbytes(width * height))
|
|
84
|
+
if colors < 256:
|
|
85
|
+
step = 256 // colors
|
|
86
|
+
for index, value in enumerate(pixels):
|
|
87
|
+
pixels[index] = (value // step) * step
|
|
88
|
+
step = preset["scanlines"]
|
|
89
|
+
if step:
|
|
90
|
+
for y in range(0, height, step):
|
|
91
|
+
start, end = y * width, (y + 1) * width
|
|
92
|
+
pixels[start:end] = bytes(value // 2 for value in pixels[start:end])
|
|
93
|
+
image = Image.frombytes("L", size, bytes(pixels))
|
|
94
|
+
if preset["flicker"]:
|
|
95
|
+
image = ImageChops.subtract(image, Image.new("L", size, rng.randrange(preset["flicker"])))
|
|
96
|
+
if preset["jitter"]:
|
|
97
|
+
image = ImageChops.offset(image, rng.randrange(-preset["jitter"], preset["jitter"] + 1), 0)
|
|
98
|
+
if preset["glitch"]:
|
|
99
|
+
for _ in range(2):
|
|
100
|
+
y = rng.randrange(height)
|
|
101
|
+
band = image.crop((0, y, width, min(height, y + rng.randrange(1, preset["glitch"] + 1))))
|
|
102
|
+
image.paste(ImageChops.offset(band, rng.randrange(-preset["glitch"], preset["glitch"] + 1), 0), (0, y))
|
|
103
|
+
return image
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format(destination: Path, requested: Optional[str]) -> tuple[Path, str]:
|
|
107
|
+
aliases = {"gif": (".gif", "GIF"), "webp": (".webp", "WEBP"), "apng": (".png", "PNG")}
|
|
108
|
+
key = (requested or {".webp": "webp", ".png": "apng"}.get(destination.suffix.lower(), "gif")).lower()
|
|
109
|
+
if key not in aliases:
|
|
110
|
+
raise ValueError("output_format must be gif, webp or apng")
|
|
111
|
+
suffix, pillow_format = aliases[key]
|
|
112
|
+
return destination.with_suffix(suffix), pillow_format
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def generate_tv_static(
|
|
116
|
+
output: PathLike, *, width: int = 820, height: int = 740, duration: float = 5,
|
|
117
|
+
fps: int = 24, background_fps: Optional[int] = None, text: Optional[str] = None,
|
|
118
|
+
text_fps: int = 6, font_path: Optional[PathLike] = None, font_size: int = 220,
|
|
119
|
+
text_position: str = "center", auto_fit: bool = True, image_path: Optional[PathLike] = None,
|
|
120
|
+
image_fps: Optional[int] = None, image_position: str = "bottom", image_scale: float = 0.25,
|
|
121
|
+
preset: str = "crt", quality: str = "high", scale: Optional[float] = None,
|
|
122
|
+
colors: Optional[int] = None, output_format: Optional[str] = None, loop: int = 0,
|
|
123
|
+
seed: Optional[int] = None, progress: Optional[ProgressCallback] = None,
|
|
124
|
+
) -> Path:
|
|
125
|
+
"""Generate TV static in GIF, animated WebP, or APNG format."""
|
|
126
|
+
if width < 1 or height < 1 or duration <= 0 or not 1 <= fps <= 100:
|
|
127
|
+
raise ValueError("width/height must be positive, duration positive, and fps between 1 and 100")
|
|
128
|
+
if preset not in PRESETS or quality not in QUALITY:
|
|
129
|
+
raise ValueError(f"preset must be one of {', '.join(PRESETS)}; quality must be one of {', '.join(QUALITY)}")
|
|
130
|
+
if text_position not in {"top", "center", "bottom"} or image_position not in {"top", "center", "bottom"}:
|
|
131
|
+
raise ValueError("positions must be top, center or bottom")
|
|
132
|
+
if not 1 <= text_fps <= fps or (image_fps is not None and not 1 <= image_fps <= fps):
|
|
133
|
+
raise ValueError("text_fps and image_fps must be between 1 and fps")
|
|
134
|
+
bg_fps = background_fps or fps
|
|
135
|
+
if not 1 <= bg_fps <= fps or font_size < 1 or image_scale <= 0 or loop < 0:
|
|
136
|
+
raise ValueError("invalid speed, font_size, image_scale or loop")
|
|
137
|
+
quality_scale, quality_colors = QUALITY[quality]
|
|
138
|
+
actual_scale = quality_scale if scale is None else scale
|
|
139
|
+
if actual_scale <= 0:
|
|
140
|
+
raise ValueError("scale must be positive")
|
|
141
|
+
size = (max(1, round(width * actual_scale)), max(1, round(height * actual_scale)))
|
|
142
|
+
palette_colors = quality_colors if colors is None else colors
|
|
143
|
+
if not 2 <= palette_colors <= 256:
|
|
144
|
+
raise ValueError("colors must be between 2 and 256")
|
|
145
|
+
destination, pillow_format = _format(Path(output), output_format)
|
|
146
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
text_mask = _text_mask(size, text, font_path, max(1, round(font_size * actual_scale)), text_position, auto_fit)
|
|
148
|
+
image_mask = _image_mask(image_path, size, image_position, image_scale)
|
|
149
|
+
count, rng = max(1, round(duration * fps)), random.Random(seed)
|
|
150
|
+
durations = [((index + 1) * round(duration * 100) // count - index * round(duration * 100) // count) * 10 for index in range(count)]
|
|
151
|
+
current_bg = current_text = current_image = None
|
|
152
|
+
seen_bg = seen_text = seen_image = -1
|
|
153
|
+
frames: list[Image.Image] = []
|
|
154
|
+
for index in range(count):
|
|
155
|
+
key = index * bg_fps // fps
|
|
156
|
+
if key != seen_bg:
|
|
157
|
+
current_bg, seen_bg = _pattern(size, rng, PRESETS[preset], palette_colors), key
|
|
158
|
+
if text_mask is not None and index * text_fps // fps != seen_text:
|
|
159
|
+
current_text, seen_text = _pattern(size, rng, PRESETS[preset], palette_colors), index * text_fps // fps
|
|
160
|
+
effective_image_fps = image_fps or text_fps
|
|
161
|
+
if image_mask is not None and index * effective_image_fps // fps != seen_image:
|
|
162
|
+
current_image, seen_image = _pattern(size, rng, PRESETS[preset], palette_colors), index * effective_image_fps // fps
|
|
163
|
+
frame = current_bg.copy()
|
|
164
|
+
if text_mask is not None and current_text is not None:
|
|
165
|
+
frame = Image.composite(current_text, frame, text_mask)
|
|
166
|
+
if image_mask is not None and current_image is not None:
|
|
167
|
+
frame = Image.composite(current_image, frame, image_mask)
|
|
168
|
+
frames.append(frame)
|
|
169
|
+
if progress:
|
|
170
|
+
progress(index + 1, count)
|
|
171
|
+
save_frames = [frame.convert("P", colors=palette_colors) if pillow_format == "GIF" else frame for frame in frames]
|
|
172
|
+
save_options = {
|
|
173
|
+
"format": pillow_format,
|
|
174
|
+
"save_all": True,
|
|
175
|
+
"append_images": save_frames[1:],
|
|
176
|
+
"duration": durations,
|
|
177
|
+
"loop": loop,
|
|
178
|
+
}
|
|
179
|
+
if pillow_format == "GIF":
|
|
180
|
+
save_options.update({"optimize": False, "disposal": 2})
|
|
181
|
+
elif pillow_format == "WEBP":
|
|
182
|
+
save_options.update({"lossless": True, "method": 6})
|
|
183
|
+
else: # APNG
|
|
184
|
+
save_options.update({"disposal": 0, "blend": 0})
|
|
185
|
+
save_frames[0].save(destination, **save_options)
|
|
186
|
+
return destination
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def generate_tv_static_gif(output: PathLike, **kwargs: object) -> Path:
|
|
190
|
+
"""Backward-compatible GIF-only wrapper around :func:`generate_tv_static`."""
|
|
191
|
+
kwargs["output_format"] = "gif"
|
|
192
|
+
return generate_tv_static(output, **kwargs) # type: ignore[arg-type]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from PIL import Image
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from tv_static_gif import generate_tv_static_gif
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_generates_five_second_gif(tmp_path):
|
|
8
|
+
output = generate_tv_static_gif(
|
|
9
|
+
tmp_path / "static", width=82, height=74, fps=24, text="TV", font_size=24
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
assert output.name == "static.gif"
|
|
13
|
+
with Image.open(output) as gif:
|
|
14
|
+
assert gif.format == "GIF"
|
|
15
|
+
assert gif.size == (82, 74)
|
|
16
|
+
assert gif.n_frames == 120
|
|
17
|
+
assert sum(
|
|
18
|
+
(gif.seek(frame), gif.info["duration"])[1]
|
|
19
|
+
for frame in range(gif.n_frames)
|
|
20
|
+
) == 5000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
"kwargs",
|
|
25
|
+
[{"width": 0}, {"height": 0}, {"duration": 0}, {"fps": 0}],
|
|
26
|
+
)
|
|
27
|
+
def test_rejects_invalid_settings(tmp_path, kwargs):
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
generate_tv_static_gif(tmp_path / "static.gif", **kwargs)
|
|
Binary file
|