isolar 0.0.1__py3-none-any.whl

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.
isolar/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """isolar — isole áudio e vídeo na velocidade da luz (FFmpeg por baixo dos panos)."""
2
+
3
+ __version__ = "0.0.1"
4
+ __author__ = "HonoravelMacho"
5
+ __license__ = "MIT"
6
+
7
+ __all__ = ["__version__", "__author__", "__license__"]
isolar/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Permite executar o pacote via `python -m isolar`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
isolar/cli.py ADDED
@@ -0,0 +1,423 @@
1
+ """CLI completa + menu interativo do isolar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Dict, Optional, Sequence, Union
9
+
10
+ from . import __version__, core
11
+ from .utils import (
12
+ IsolarError,
13
+ InvalidTimeError,
14
+ MediaNotFoundError,
15
+ bold,
16
+ check_dependencies,
17
+ cyan,
18
+ dim,
19
+ format_time,
20
+ get_media_info,
21
+ green,
22
+ human_size,
23
+ red,
24
+ warn,
25
+ yellow,
26
+ )
27
+
28
+ PROG = "isolar"
29
+
30
+ USAGE_EXAMPLES = """exemplos:
31
+ isolar abre o menu interativo
32
+ isolar audio video.mp4 extrai o áudio para video_audio.mp3
33
+ isolar audio video.mp4 -f flac extrai o áudio sem perdas (FLAC)
34
+ isolar audio aula.mp4 -f copy cópia direta da faixa (sem recodificar)
35
+ isolar video video.mp4 gera video_mudo.mp4 (sem som)
36
+ isolar cut video.mp4 -s 10 -d 45 recorta 45s a partir do segundo 10
37
+ isolar cut video.mp4 -s 00:01:30 -e 00:03:00
38
+ isolar cut musica.wav -s 30 -e 60 --reencode corte exato (recodifica)
39
+ isolar info video.mp4 mostra duração, codecs e tamanho
40
+ isolar --version mostra a versão
41
+ isolar --help mostra esta ajuda"""
42
+
43
+ _MENU_ITEMS = [
44
+ ("1", "🎵", "Isolar áudio", "extrai o áudio de um vídeo"),
45
+ ("2", "🎬", "Isolar vídeo", "gera um vídeo sem som"),
46
+ ("3", "✂ ", "Cortar/aparar", "corte ultrarrápido (stream copy)"),
47
+ ("4", "ℹ ", "Info da mídia", "duração, codecs, tamanho..."),
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Feedback
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _banner() -> None:
56
+ print()
57
+ print(cyan(" ╔" + "═" * 48))
58
+ print(cyan(" ║ ") + bold(f"🎧 {PROG.upper()}") + dim(f" v{__version__}"))
59
+ print(cyan(" ║ ") + dim("isole áudio e vídeo na velocidade da luz ✨"))
60
+ print(cyan(" ╚" + "═" * 48))
61
+
62
+
63
+ def _print_result(out: Path) -> None:
64
+ size = out.stat().st_size if out.exists() else None
65
+ print()
66
+ print(green(f"✔ Pronto! Arquivo salvo em: {out}"))
67
+ if size is not None:
68
+ print(dim(f" tamanho: {human_size(size)}"))
69
+
70
+
71
+ def _show_info(src: Path) -> None:
72
+ info = get_media_info(src)
73
+ video = "—"
74
+ if info["has_video"]:
75
+ video = f"{info['video_codec']} · {info['width']}x{info['height']}"
76
+ audio = "—"
77
+ if info["has_audio"]:
78
+ audio = (
79
+ f"{info['audio_codec']} · {info['audio_sample_rate'] or '?'} Hz · "
80
+ f"{info['audio_channels'] or '?'} canal(is)"
81
+ )
82
+ rows = [
83
+ ("Arquivo", str(info["path"])),
84
+ ("Container", info["format_name"] or "?"),
85
+ ("Duração", format_time(info["duration"])),
86
+ ("Tamanho", human_size(info["size"])),
87
+ ("Streams", str(info["nb_streams"])),
88
+ ("Vídeo", video),
89
+ ("Áudio", audio),
90
+ ("Faixas de áudio", str(info["audio_streams"])),
91
+ ]
92
+ print()
93
+ for key, value in rows:
94
+ print(f" {dim(key + ':')} {value}")
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Entradas interativas
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _ask(question: str, default: str = "") -> str:
102
+ suffix = f" [{default}]" if default else ""
103
+ return input(cyan(f" {question}{suffix}: ")).strip() or default
104
+
105
+
106
+ def _ask_yes_no(question: str, default: bool = True) -> bool:
107
+ suffix = "S/n" if default else "s/N"
108
+ raw = _ask(f"{question} ({suffix})", "").lower()
109
+ if not raw:
110
+ return default
111
+ return raw in ("s", "sim", "y", "yes", "1", "true")
112
+
113
+
114
+ def _ask_path(prompt: str = "📁 Caminho do arquivo") -> Path:
115
+ while True:
116
+ raw = _ask(prompt, "").strip('"').strip("'")
117
+ src = Path(raw).expanduser() if raw else None
118
+ if src is not None and src.is_file():
119
+ return src
120
+ print(red(f" ✖ Arquivo não encontrado: {raw or '(nada informado)'}"))
121
+
122
+
123
+ def _ask_audio_format() -> str:
124
+ keys = list(core.AUDIO_FORMATS)
125
+ print(dim(" Formatos suportados (FFmpeg por baixo dos panos):"))
126
+ for index, key in enumerate(keys, start=1):
127
+ print(f" {index:2d}) {key:<5} — {core.AUDIO_FORMATS[key]['label']}")
128
+ raw = _ask("Formato", "1")
129
+ if raw.isdigit() and 1 <= int(raw) <= len(keys):
130
+ return keys[int(raw) - 1]
131
+ warn("Opção inválida — usando MP3.")
132
+ return "mp3"
133
+
134
+
135
+ def _ask_track(audio_streams: int) -> int:
136
+ if audio_streams <= 1:
137
+ return 0
138
+ raw = _ask(f"Qual faixa de áudio? (1 a {audio_streams})", "1")
139
+ index = int(raw) - 1 if raw.isdigit() else 0
140
+ if not 0 <= index < audio_streams:
141
+ warn("Faixa inválida — usando a primeira.")
142
+ return 0
143
+ return index
144
+
145
+
146
+ def _ask_bitrate(default: str = "192k") -> str:
147
+ raw = _ask("Bitrate (128k/192k/320k...)", default)
148
+ if re.match(r"^\d+k$", raw.lower()):
149
+ return raw.lower()
150
+ if raw.isdigit():
151
+ return f"{raw}k"
152
+ if raw != default:
153
+ warn(f"Bitrate inválido — usando {default}.")
154
+ return default
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Fluxos do menu interativo
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _flow_audio() -> None:
162
+ src = _ask_path()
163
+ info = get_media_info(src)
164
+ if not info["has_audio"]:
165
+ raise IsolarError(f"'{src.name}' não possui faixa de áudio.")
166
+ track = _ask_track(info["audio_streams"])
167
+ fmt = _ask_audio_format()
168
+ bitrate = "192k"
169
+ if fmt not in ("wav", "flac", "aiff", "copy"):
170
+ bitrate = _ask_bitrate()
171
+ out = core.isolate_audio(src, fmt=fmt, bitrate=bitrate, track=track)
172
+ _print_result(out)
173
+
174
+
175
+ def _flow_video() -> None:
176
+ src = _ask_path()
177
+ out = core.isolate_video(src)
178
+ _print_result(out)
179
+
180
+
181
+ def _flow_cut() -> None:
182
+ src = _ask_path()
183
+ info = get_media_info(src)
184
+ print(dim(f" ✦ {core.describe_media(info)}"))
185
+ start = _ask("Início (ex: 10, 1:30, 00:01:30.500)", "0")
186
+ end = _ask("Fim (ex: 01:05:00)", "")
187
+ duration = ""
188
+ if not end:
189
+ duration = _ask("Duração (ex: 60 ou 1m30s)", "")
190
+ fast = _ask_yes_no("Corte rápido sem recodificar (stream copy)", True)
191
+ out = core.cut_media(
192
+ src,
193
+ start=start,
194
+ end=end or None,
195
+ duration=duration or None,
196
+ reencode=not fast,
197
+ )
198
+ _print_result(out)
199
+
200
+
201
+ def _flow_info() -> None:
202
+ _show_info(_ask_path())
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Menu interativo
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def _interactive() -> int:
210
+ _banner()
211
+ flows: Dict[str, Callable[[], None]] = {
212
+ "1": _flow_audio,
213
+ "2": _flow_video,
214
+ "3": _flow_cut,
215
+ "4": _flow_info,
216
+ }
217
+ while True:
218
+ print()
219
+ print(bold(" O que você quer isolar hoje?"))
220
+ for key, icon, title, hint in _MENU_ITEMS:
221
+ print(f" {key}) {icon} {title:<15} {dim(hint)}")
222
+ print(" 0) ✖ Sair")
223
+ try:
224
+ choice = input(cyan("\n Escolha uma opção [0-4]: ")).strip().lower()
225
+ except EOFError:
226
+ print()
227
+ return 0
228
+
229
+ if choice in ("0", "", "q", "quit", "exit", "sair"):
230
+ print(dim("\n Até mais! 👋\n"))
231
+ return 0
232
+ if choice not in flows:
233
+ warn("Opção inválida — escolha um número do menu.")
234
+ continue
235
+
236
+ try:
237
+ flows[choice]()
238
+ except IsolarError as exc:
239
+ print(red(f" ✖ {exc}"))
240
+ except KeyboardInterrupt:
241
+ print(yellow("\n ✋ Operação cancelada — voltando ao menu."))
242
+ print()
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # CLI (flags)
247
+ # ---------------------------------------------------------------------------
248
+
249
+ def _ensure_input(value: Optional[str], prompt: str) -> Union[str, Path]:
250
+ if value:
251
+ return value
252
+ return _ask_path(prompt)
253
+
254
+
255
+ def _run_audio(args: argparse.Namespace) -> int:
256
+ src = _ensure_input(args.input, "📁 Arquivo para extrair o áudio")
257
+ out = core.isolate_audio(
258
+ src,
259
+ fmt=args.format,
260
+ bitrate=args.bitrate,
261
+ track=args.track,
262
+ output=args.output,
263
+ )
264
+ _print_result(out)
265
+ return 0
266
+
267
+
268
+ def _run_video(args: argparse.Namespace) -> int:
269
+ src = _ensure_input(args.input, "📁 Arquivo de vídeo (para remover o áudio)")
270
+ out = core.isolate_video(src, output=args.output)
271
+ _print_result(out)
272
+ return 0
273
+
274
+
275
+ def _run_cut(args: argparse.Namespace) -> int:
276
+ src = _ensure_input(args.input, "📁 Arquivo para cortar")
277
+ out = core.cut_media(
278
+ src,
279
+ start=args.start,
280
+ end=args.end,
281
+ duration=args.duration,
282
+ reencode=args.reencode,
283
+ output=args.output,
284
+ )
285
+ _print_result(out)
286
+ return 0
287
+
288
+
289
+ def _run_info(args: argparse.Namespace) -> int:
290
+ src = _ensure_input(args.input, "📁 Arquivo para inspecionar")
291
+ _show_info(Path(str(src)))
292
+ return 0
293
+
294
+
295
+ _HANDLERS: Dict[str, Callable[[argparse.Namespace], int]] = {
296
+ "audio": _run_audio,
297
+ "video": _run_video,
298
+ "cut": _run_cut,
299
+ "info": _run_info,
300
+ }
301
+
302
+
303
+ def _build_parser() -> argparse.ArgumentParser:
304
+ parser = argparse.ArgumentParser(
305
+ prog=PROG,
306
+ description=(
307
+ "isolar — isole áudio e vídeo na velocidade da luz.\n"
308
+ "Extração de áudio, remoção de faixa de som e cortes ultrarrápidos "
309
+ "(cópia direta de stream) com FFmpeg por baixo dos panos."
310
+ ),
311
+ epilog=USAGE_EXAMPLES,
312
+ formatter_class=argparse.RawDescriptionHelpFormatter,
313
+ )
314
+ parser.add_argument(
315
+ "-V", "--version",
316
+ action="version",
317
+ version=f"%(prog)s {__version__}",
318
+ help="mostra a versão do isolar e sai",
319
+ )
320
+ parser.add_argument(
321
+ "-v", "--verbose",
322
+ action="store_true",
323
+ help="(reservado) modo detalhado",
324
+ )
325
+
326
+ sub = parser.add_subparsers(dest="command", metavar="<comando>")
327
+
328
+ p_audio = sub.add_parser(
329
+ "audio",
330
+ help="extrai o áudio de um vídeo (mp3, m4a, wav, flac, ogg, opus, copy...)",
331
+ epilog=USAGE_EXAMPLES,
332
+ formatter_class=argparse.RawDescriptionHelpFormatter,
333
+ )
334
+ p_audio.add_argument("input", nargs="?", help="arquivo de entrada (vídeo)")
335
+ p_audio.add_argument(
336
+ "-f", "--format",
337
+ choices=list(core.AUDIO_FORMATS),
338
+ default="mp3",
339
+ help="formato de saída (padrão: mp3)",
340
+ )
341
+ p_audio.add_argument(
342
+ "-b", "--bitrate",
343
+ default="192k",
344
+ help="bitrate para formatos com perdas (padrão: 192k)",
345
+ )
346
+ p_audio.add_argument(
347
+ "-t", "--track",
348
+ type=int,
349
+ default=0,
350
+ help="índice da faixa de áudio, começando em 0 (padrão: 0)",
351
+ )
352
+ p_audio.add_argument(
353
+ "-o", "--output",
354
+ help="arquivo ou pasta de saída (padrão: pasta de trabalho atual)",
355
+ )
356
+
357
+ p_video = sub.add_parser(
358
+ "video",
359
+ help="remove o áudio do vídeo, gerando um arquivo mudo",
360
+ epilog=USAGE_EXAMPLES,
361
+ formatter_class=argparse.RawDescriptionHelpFormatter,
362
+ )
363
+ p_video.add_argument("input", nargs="?", help="arquivo de vídeo de entrada")
364
+ p_video.add_argument(
365
+ "-o", "--output",
366
+ help="arquivo ou pasta de saída (padrão: pasta de trabalho atual)",
367
+ )
368
+
369
+ p_cut = sub.add_parser(
370
+ "cut",
371
+ help="corta/apara a mídia sem recodificar (fast cut, -c copy)",
372
+ epilog=USAGE_EXAMPLES,
373
+ formatter_class=argparse.RawDescriptionHelpFormatter,
374
+ )
375
+ p_cut.add_argument("input", nargs="?", help="arquivo de mídia de entrada")
376
+ p_cut.add_argument("-s", "--start", default=None, help="ponto de início (ex: 90, 1:30, 00:01:30.5)")
377
+ p_cut.add_argument("-e", "--end", default=None, help="ponto de fim (ex: 00:03:00)")
378
+ p_cut.add_argument("-d", "--duration", default=None, help="duração do recorte (ex: 45, 1m30s)")
379
+ p_cut.add_argument(
380
+ "--reencode",
381
+ action="store_true",
382
+ help="recodifica para um corte exato ao frame (mais lento)",
383
+ )
384
+ p_cut.add_argument(
385
+ "-o", "--output",
386
+ help="arquivo ou pasta de saída (padrão: pasta de trabalho atual)",
387
+ )
388
+
389
+ p_info = sub.add_parser(
390
+ "info",
391
+ help="mostra duração, codecs e tamanho da mídia",
392
+ epilog=USAGE_EXAMPLES,
393
+ formatter_class=argparse.RawDescriptionHelpFormatter,
394
+ )
395
+ p_info.add_argument("input", nargs="?", help="arquivo de mídia de entrada")
396
+
397
+ return parser
398
+
399
+
400
+ def main(argv: Optional[Sequence[str]] = None) -> int: # noqa: C901
401
+ """Ponto de entrada: flags diretas ou menu interativo quando sem argumentos."""
402
+ parser = _build_parser()
403
+ args = parser.parse_args(argv)
404
+ handler = _HANDLERS.get(args.command) # None → menu interativo
405
+
406
+ try:
407
+ check_dependencies()
408
+ if handler is None:
409
+ return _interactive()
410
+ return handler(args)
411
+ except KeyboardInterrupt:
412
+ print(yellow("\n✋ Operação cancelada pelo usuário."))
413
+ return 130
414
+ except EOFError:
415
+ print(yellow("\n✋ Entrada encerrada."))
416
+ return 130
417
+ except (MediaNotFoundError, InvalidTimeError) as exc:
418
+ print(red(f"✖ {exc}"))
419
+ print(dim(" Dica: rode 'isolar --help' para ver os exemplos de uso."))
420
+ return 1
421
+ except IsolarError as exc:
422
+ print(red(f"✖ {exc}"))
423
+ return 1