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.
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -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,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ *.gif
9
+ !assets/
10
+ !assets/examples/
11
+ !assets/examples/*.gif
12
+ !assets/examples/*.png
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Presets CRT, VHS e sinal fraco.
6
+ - Texto e imagens com velocidade, posição e ajuste automático próprios.
7
+ - Opções de qualidade, escala, cores, loop e formatos GIF, WebP e APNG.
8
+ - CLI com preview, diretório de saída e versão.
@@ -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
+ ![Exemplo CRT com texto TV](assets/examples/crt-text.gif)
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
+ ![Exemplo de sinal fraco com marca](assets/examples/weak-signal-logo.gif)
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
+ ![Exemplo CRT com texto TV](assets/examples/crt-text.gif)
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
+ ![Exemplo de sinal fraco com marca](assets/examples/weak-signal-logo.gif)
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).
@@ -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,6 @@
1
+ """Create animated GIFs that resemble analog television static."""
2
+
3
+ from .generator import generate_tv_static, generate_tv_static_gif
4
+
5
+ __all__ = ["generate_tv_static", "generate_tv_static_gif"]
6
+ __version__ = "0.2.0"
@@ -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