primocode 10.16.0 → 10.17.0

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.
Files changed (234) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +11 -2
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,1146 @@
1
+ """Personal soundtrack library organized by mood (animada, triste, tensa...).
2
+
3
+ The person keeps ready tracks per feeling and, while editing, just says "trilha triste" (or picks the mood in
4
+ the preview): nothing is generated again.
5
+
6
+ Where it lives: OUTSIDE the skill folder, so it survives reinstalls and never ships with the product.
7
+ macOS / Linux: ~/.editor-video/trilhas Windows: %USERPROFILE%\\.editor-video\\trilhas
8
+ EDITOR_VIDEO_TRILHAS overrides it (the tests point it to a temporary folder). A library inside a skill
9
+ folder is refused (an install would delete it and a package would carry it).
10
+
11
+ Layout: one subfolder per mood id (triste/triste-1.wav ...), `_lixeira/` for removed tracks (nothing is ever
12
+ hard-deleted), `_recebidas/` for generated files waiting to enter, and the index `trilhas.json`:
13
+ {"version": 1,
14
+ "moods": {id: {"label", "when", "created_at"}}, custom moods only (presets: trilhas_humores.json)
15
+ "tracks": [{"id", "mood", "file" (relative to the library), "title", "duration_s", "integrated_lufs",
16
+ "energy", "full_at_s", "voice_band_share", "source", "license", "prompt", "added_at",
17
+ "favorite", "original_name", "format", "sha256" (of the stored file)}],
18
+ "lixeira": [same entries + "removed_at"],
19
+ "ignoradas": [hand-edited entries without a text id, mood and file, kept aside instead of dropped]}
20
+ A "file" must be a plain relative path inside the library (library_file): anything else counts as missing.
21
+ Lossy files (mp3, m4a, aac, ogg, opus) are copied byte for byte (no second lossy generation); wav, flac,
22
+ aiff and the sound of a video are converted to 48 kHz stereo WAV (16-bit when the source is 16-bit, else
23
+ 24-bit).
24
+
25
+ Measurements: integrated loudness (ffmpeg loudnorm, the same reading compose_final uses), duration, an energy
26
+ note from 0.5 s RMS windows ("cheia desde o início" when the level is within 4 dB of the track's 90th
27
+ percentile in the first second, else "começa baixa e cresce aos N s"; "; cai aos N s" for a dip of 12 dB or
28
+ more lasting 1.5 s before the last 3 s) and the voice-band share (energy between 300 and 3400 Hz over the
29
+ energy between 20 Hz and 12 kHz, mono at 24 kHz).
30
+
31
+ Choosing (`escolher` and compose_final's music.mood): the favorite of the mood; else the track with the lowest
32
+ score = 3 * uncovered share of (cut + 1.5 s) + late start share + voice-band share (ties: closest length to
33
+ cut + 2 s, then id). The choice is copied to <EDIT>/assets/trilha_<mood>.<ext> (never over a file this helper
34
+ did not write, checked by content) and recorded in <EDIT>/assets/trilhas_escolhidas.json, so the next render
35
+ of the same edit uses the same file even if the library changed or is gone. `escolher` always chooses again;
36
+ `--trocar` skips the track the edit has now; `--trilha ID` picks one.
37
+
38
+ Usage:
39
+ uv run python helpers/trilhas.py onde
40
+ uv run python helpers/trilhas.py humores [--json]
41
+ uv run python helpers/trilhas.py listar [--humor ID] [--json]
42
+ uv run python helpers/trilhas.py adicionar ARQUIVO --humor ID [--titulo T] [--favorita] [--fonte F]
43
+ [--prompt P] [--licenca L] [--novo-humor] [--quando TEXTO]
44
+ uv run python helpers/trilhas.py favorita ID [--tirar]
45
+ uv run python helpers/trilhas.py remover ID
46
+ uv run python helpers/trilhas.py restaurar ID
47
+ uv run python helpers/trilhas.py escolher --humor ID --edit-dir EDIT [--duracao S] [--trilha ID] [--trocar]
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import difflib
54
+ import hashlib
55
+ import json
56
+ import math
57
+ import os
58
+ import re
59
+ import shutil
60
+ import subprocess
61
+ import sys
62
+ import unicodedata
63
+ from pathlib import Path
64
+
65
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
66
+ from common import HELPERS_DIR, SKILL_ROOT, ffprobe_json, load_json, now_iso, save_json # noqa: E402
67
+
68
+ LIBRARY_ENV = "EDITOR_VIDEO_TRILHAS"
69
+ INDEX_NAME = "trilhas.json"
70
+ TRASH_DIR = "_lixeira"
71
+ INBOX_DIR = "_recebidas"
72
+ PRESETS_PATH = HELPERS_DIR / "trilhas_humores.json"
73
+ LOCK_NAME = "trilhas_escolhidas.json" # in <EDIT>/assets
74
+ LOSSY_KEEP = (".mp3", ".m4a", ".aac", ".ogg", ".opus")
75
+ LOSSLESS_CONVERT = (".wav", ".wave", ".flac", ".aif", ".aiff")
76
+ VIDEO_CONVERT = (".mp4", ".mov", ".m4v", ".mkv", ".webm")
77
+ AUDIO_EXT = LOSSY_KEEP + LOSSLESS_CONVERT
78
+ SOURCE_USER = "arquivo do usuário"
79
+ LICENSE_USER = "arquivo da pessoa: usar só com direito de uso (música própria ou licença comercial)"
80
+ MIN_SECONDS = 1.0
81
+ SR_MEASURE = 24000
82
+ WINDOW_S = 0.5
83
+ FULL_WITHIN_DB = 4.0
84
+ DIP_DB = 12.0
85
+ DIP_MIN_S = 1.5
86
+ TAIL_IGNORED_S = 3.0
87
+ VOICE_BAND = (300.0, 3400.0)
88
+ FULL_BAND = (20.0, 12000.0)
89
+ FADE_ROOM_S = 1.5 # compose fades the last 1.5 s: a track should cover cut + this
90
+ MOOD_ID = re.compile(r"^[a-z0-9][a-z0-9_]{0,39}$")
91
+ RESERVED_MOODS = ("off", "arquivo", "sem", "nenhum", "nenhuma") # choices of the preview's Trilha control
92
+
93
+
94
+ class TrilhaError(Exception):
95
+ def __init__(self, message: str, code: int = 2):
96
+ super().__init__(message)
97
+ self.code = code
98
+
99
+
100
+ # ------------------------------------------------------------------ text helpers
101
+
102
+ def slug(text) -> str:
103
+ """'Épica' -> 'epica', 'Motivacional leve' -> 'motivacional_leve' (mood ids)."""
104
+ t = unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode("ascii").lower()
105
+ return re.sub(r"[^a-z0-9]+", "_", t).strip("_")[:40].strip("_")
106
+
107
+
108
+ def num(value: float, digits: int = 1) -> str:
109
+ """-14.25 -> '-14,2' (PT-BR decimal comma)."""
110
+ text = f"{value:.{digits}f}"
111
+ if "." in text:
112
+ text = text.rstrip("0").rstrip(".")
113
+ return text.replace(".", ",")
114
+
115
+
116
+ def dur(seconds: float) -> str:
117
+ """64.3 -> '1:04'."""
118
+ s = int(round(max(0.0, float(seconds))))
119
+ return f"{s // 60}:{s % 60:02d}"
120
+
121
+
122
+ def skill_cmd(helper: str, args: str) -> str:
123
+ return f"uv run --directory '{SKILL_ROOT}' python helpers/{helper} {args}"
124
+
125
+
126
+ # ------------------------------------------------------------------ location
127
+
128
+ def default_library() -> Path:
129
+ return Path.home() / ".editor-video" / "trilhas"
130
+
131
+
132
+ def library_dir() -> Path:
133
+ raw = (os.environ.get(LIBRARY_ENV) or "").strip()
134
+ return Path(raw).expanduser().absolute() if raw else default_library()
135
+
136
+
137
+ def _looks_like_skill(folder: Path) -> bool:
138
+ try:
139
+ md = folder / "SKILL.md"
140
+ return md.is_file() and "name: editor-video" in md.read_text(encoding="utf-8", errors="replace")[:600]
141
+ except OSError:
142
+ return False
143
+
144
+
145
+ def check_location(lib: Path | None = None) -> Path:
146
+ """The library folder, refused when it sits inside a skill folder (an install would delete it)."""
147
+ lib = Path(lib).expanduser().absolute() if lib else library_dir()
148
+ try:
149
+ resolved = lib.resolve()
150
+ except OSError:
151
+ resolved = lib
152
+ skill = SKILL_ROOT.resolve()
153
+ for folder in (resolved, *resolved.parents):
154
+ if folder == skill or _looks_like_skill(folder):
155
+ raise TrilhaError(f"a biblioteca de trilhas não pode ficar dentro da pasta de uma skill ({folder}): a "
156
+ "próxima instalação apagaria as músicas e o pacote da skill levaria junto. Use a pasta "
157
+ f"padrão ({default_library()}) ou aponte {LIBRARY_ENV} para uma pasta fora da skill.")
158
+ return lib
159
+
160
+
161
+ # ------------------------------------------------------------------ presets and index
162
+
163
+ def load_presets() -> dict:
164
+ try:
165
+ data = load_json(PRESETS_PATH) or {}
166
+ except (OSError, ValueError) as e:
167
+ raise TrilhaError(f"não consegui ler {PRESETS_PATH.name} da skill ({e.__class__.__name__}). "
168
+ "Instale a skill de novo.", 1)
169
+ return data if isinstance(data, dict) else {}
170
+
171
+
172
+ def preset_moods() -> list[dict]:
173
+ return [m for m in (load_presets().get("moods") or []) if isinstance(m, dict) and m.get("id")]
174
+
175
+
176
+ def empty_index() -> dict:
177
+ return {"version": 1, "moods": {}, "tracks": [], "lixeira": []}
178
+
179
+
180
+ def fill_hint(mood_id: str) -> str:
181
+ return (f"Para pôr música no humor {mood_id}: (1) um arquivo da pessoa, com direito de uso: "
182
+ f"{skill_cmd('trilhas.py', f'adicionar ARQUIVO --humor {mood_id}')} (2) ou uma gerada com IA na conta "
183
+ f"dela, paga e só com o ok dela: {skill_cmd('gen_music.py', f'--humor {mood_id} --biblioteca --dry-run')}")
184
+
185
+
186
+ def missing_library_message(lib: Path, mood_id: str = "triste") -> str:
187
+ return (f"a biblioteca de trilhas ainda não existe ({lib}). Ela é criada quando a primeira música entra. "
188
+ f"{fill_hint(mood_id)}. Outra saída: usar um arquivo direto no edit_data.json "
189
+ "(\"music\": {\"enabled\": true, \"file\": \"assets/minha_musica.mp3\"}).")
190
+
191
+
192
+ def load_index(lib: Path, required: bool = True) -> dict | None:
193
+ path = lib / INDEX_NAME
194
+ if not path.is_file():
195
+ if required:
196
+ raise TrilhaError(missing_library_message(lib))
197
+ return None
198
+ try:
199
+ data = json.loads(path.read_text(encoding="utf-8"))
200
+ except (OSError, ValueError) as e:
201
+ raise TrilhaError(f"não consegui ler o índice da biblioteca ({path}): {e.__class__.__name__}. Nada foi "
202
+ "mudado. Confira o arquivo antes de seguir.")
203
+ if not isinstance(data, dict) or not isinstance(data.get("tracks", []), list) \
204
+ or not isinstance(data.get("moods", {}), dict) or not isinstance(data.get("lixeira", []), list):
205
+ raise TrilhaError(f"o índice da biblioteca ({path}) não tem o formato esperado. Nada foi mudado.")
206
+ for key, value in empty_index().items():
207
+ data.setdefault(key, value)
208
+ # entries edited by hand: unusable ones are kept aside under "ignoradas" (never lost), numbers are checked
209
+ ignored = data["ignoradas"] if isinstance(data.get("ignoradas"), list) else []
210
+ for key in ("tracks", "lixeira"):
211
+ good = []
212
+ for t in data[key]:
213
+ if _usable_entry(t):
214
+ good.append(_clean_entry(t))
215
+ else:
216
+ ignored.append(t)
217
+ data[key] = good
218
+ if ignored:
219
+ data["ignoradas"] = ignored
220
+ return data
221
+
222
+
223
+ TRACK_NUMBERS = ("duration_s", "full_at_s", "voice_band_share", "integrated_lufs")
224
+
225
+
226
+ def _usable_entry(t) -> bool:
227
+ return isinstance(t, dict) and all(isinstance(t.get(k), str) and t[k].strip() for k in ("id", "mood", "file"))
228
+
229
+
230
+ def _number(value) -> float | None:
231
+ if isinstance(value, bool):
232
+ return None
233
+ try:
234
+ value = float(value)
235
+ except (TypeError, ValueError):
236
+ return None
237
+ return value if math.isfinite(value) else None
238
+
239
+
240
+ def _clean_entry(t: dict) -> dict:
241
+ for key in TRACK_NUMBERS:
242
+ if key in t:
243
+ t[key] = _number(t[key])
244
+ t["favorite"] = t.get("favorite") is True
245
+ if not isinstance(t.get("title"), str) or not t["title"].strip():
246
+ t["title"] = t["id"]
247
+ return t
248
+
249
+
250
+ def library_file(lib: Path, rel) -> Path | None:
251
+ """<lib>/<rel> for a file named in the index; None unless rel is a plain relative path inside the library
252
+ (a hand-edited index must never make remover or escolher touch a file outside it)."""
253
+ if not isinstance(rel, str) or not rel.strip() or "\x00" in rel:
254
+ return None
255
+ parts = rel.replace("\\", "/").split("/")
256
+ if rel.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", rel) or any(p in ("", ".", "..") for p in parts):
257
+ return None
258
+ return lib.joinpath(*parts)
259
+
260
+
261
+ def _has_file(lib: Path, t: dict) -> bool:
262
+ path = library_file(lib, t.get("file"))
263
+ return path is not None and path.is_file()
264
+
265
+
266
+ def save_index(lib: Path, index: dict) -> None:
267
+ try:
268
+ save_json(lib / INDEX_NAME, index)
269
+ except OSError as e:
270
+ raise TrilhaError(f"não consegui gravar o índice da biblioteca em {lib} ({e.__class__.__name__}). "
271
+ "Confira o espaço e a permissão da pasta.", 1)
272
+
273
+
274
+ def all_moods(index: dict | None) -> list[dict]:
275
+ out, seen = [], set()
276
+ for m in preset_moods():
277
+ out.append({"id": m["id"], "label": m.get("label") or m["id"], "when": m.get("when") or "",
278
+ "prompt": m.get("prompt"), "custom": False})
279
+ seen.add(m["id"])
280
+ for mid, info in sorted(((index or {}).get("moods") or {}).items()):
281
+ if mid in seen or not MOOD_ID.match(str(mid)) or mid in RESERVED_MOODS:
282
+ continue
283
+ info = info if isinstance(info, dict) else {}
284
+ text = {k: (info.get(k) if isinstance(info.get(k), str) else "") for k in ("label", "when", "prompt")}
285
+ out.append({"id": mid, "label": text["label"].strip() or mid, "when": text["when"],
286
+ "prompt": text["prompt"] or None, "custom": True})
287
+ seen.add(mid)
288
+ for t in (index or {}).get("tracks") or []:
289
+ mid = t.get("mood") if isinstance(t, dict) else None
290
+ if isinstance(mid, str) and mid not in seen and MOOD_ID.match(mid) and mid not in RESERVED_MOODS:
291
+ # a mood the "moods" list lost
292
+ out.append({"id": mid, "label": mid, "when": "", "prompt": None, "custom": True})
293
+ seen.add(mid)
294
+ return out
295
+
296
+
297
+ def find_mood(index: dict | None, text) -> dict | None:
298
+ key = slug(text)
299
+ if not key:
300
+ return None
301
+ for m in all_moods(index):
302
+ if key in (m["id"], slug(m["label"])):
303
+ return m
304
+ return None
305
+
306
+
307
+ def unknown_mood_message(text, index: dict | None) -> str:
308
+ ids = ", ".join(m["id"] for m in all_moods(index))
309
+ return f"o humor '{text}' não existe. Humores: {ids}. Lista completa: {skill_cmd('trilhas.py', 'humores')}"
310
+
311
+
312
+ def mood_tracks(lib: Path, index: dict, mood_id: str, existing_only: bool = True) -> list[dict]:
313
+ tracks = [t for t in index.get("tracks", []) if t.get("mood") == mood_id]
314
+ if existing_only:
315
+ tracks = [t for t in tracks if _has_file(lib, t)]
316
+ return sorted(tracks, key=lambda t: (not t.get("favorite"), str(t.get("added_at", "")), str(t["id"])))
317
+
318
+
319
+ def find_track(index: dict, track_id: str) -> dict | None:
320
+ return next((t for t in index.get("tracks", []) if t.get("id") == track_id), None)
321
+
322
+
323
+ def next_id(lib: Path, index: dict, mood_id: str) -> str:
324
+ used = [0]
325
+ for t in list(index.get("tracks", [])) + list(index.get("lixeira", [])):
326
+ m = re.fullmatch(re.escape(mood_id) + r"-(\d+)", str(t.get("id", "")))
327
+ if m:
328
+ used.append(int(m.group(1)))
329
+ n = max(used) + 1
330
+ while list((lib / mood_id).glob(f"{mood_id}-{n}.*")):
331
+ n += 1
332
+ return f"{mood_id}-{n}"
333
+
334
+
335
+ # ------------------------------------------------------------------ measuring
336
+
337
+ def sha256(path: Path) -> str:
338
+ h = hashlib.sha256()
339
+ with open(str(path), "rb") as f:
340
+ for chunk in iter(lambda: f.read(1 << 20), b""):
341
+ h.update(chunk)
342
+ return h.hexdigest()
343
+
344
+
345
+ def need_ffmpeg() -> None:
346
+ for tool in ("ffmpeg", "ffprobe"):
347
+ if not shutil.which(tool):
348
+ raise TrilhaError(f"'{tool}' não encontrado. Mac: brew install ffmpeg · Windows: winget install Gyan.FFmpeg", 1)
349
+
350
+
351
+ def probe_audio(path: Path, name: str | None = None) -> dict:
352
+ name = name or path.name
353
+ try:
354
+ info = ffprobe_json(path)
355
+ except (RuntimeError, ValueError, OSError):
356
+ raise TrilhaError(f"não consegui abrir {name} como áudio (arquivo corrompido ou de outro tipo).")
357
+ audio = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), None)
358
+ if audio is None:
359
+ raise TrilhaError(f"{name} não tem som.")
360
+ try:
361
+ seconds = float(audio.get("duration") or (info.get("format") or {}).get("duration") or 0.0)
362
+ except (TypeError, ValueError):
363
+ seconds = 0.0
364
+ return {"duration": seconds, "codec": audio.get("codec_name") or "", "sample_fmt": audio.get("sample_fmt") or "",
365
+ "sample_rate": int(audio.get("sample_rate") or 0), "channels": int(audio.get("channels") or 0)}
366
+
367
+
368
+ def integrated_lufs(path: Path) -> float | None:
369
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "info", "-i", str(path), "-vn", "-af",
370
+ "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
371
+ capture_output=True, text=True)
372
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr or "", re.S)
373
+ try:
374
+ value = float(json.loads(m.group(0))["input_i"]) if m else None
375
+ except (ValueError, KeyError):
376
+ return None
377
+ return value if value is not None and math.isfinite(value) else None
378
+
379
+
380
+ def decode_mono(path: Path, sr: int = SR_MEASURE, name: str | None = None):
381
+ import numpy as np
382
+
383
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(path), "-vn", "-ac", "1",
384
+ "-ar", str(sr), "-f", "f32le", "-"], capture_output=True)
385
+ if proc.returncode != 0:
386
+ raise TrilhaError(f"não consegui ler o som de {name or path.name}.")
387
+ data = proc.stdout[: len(proc.stdout) // 4 * 4]
388
+ return np.frombuffer(data, dtype="<f4").astype(np.float64)
389
+
390
+
391
+ def energy_profile(x, sr: int = SR_MEASURE) -> tuple[str, float, float | None]:
392
+ """(note, full_at_s, dip_at_s) from 0.5 s RMS windows."""
393
+ import numpy as np
394
+
395
+ win = max(1, int(WINDOW_S * sr))
396
+ n = len(x) // win
397
+ if n == 0:
398
+ return "cheia desde o início", 0.0, None
399
+ power = (x[: n * win].reshape(n, win) ** 2).mean(axis=1)
400
+ level = 10 * np.log10(power + 1e-12)
401
+ ref = float(np.percentile(level, 90))
402
+ full_idx = int(np.argmax(level >= ref - FULL_WITHIN_DB))
403
+ full_at = full_idx * WINDOW_S
404
+ note = "cheia desde o início" if full_at <= 1.0 else f"começa baixa e cresce aos {num(full_at)} s"
405
+ dip_at = None
406
+ need = int(round(DIP_MIN_S / WINDOW_S))
407
+ last = n - int(round(TAIL_IGNORED_S / WINDOW_S))
408
+ run = 0
409
+ for i in range(full_idx + 1, max(full_idx + 1, last)):
410
+ run = run + 1 if level[i] < ref - DIP_DB else 0
411
+ if run >= need:
412
+ dip_at = (i - need + 1) * WINDOW_S
413
+ break
414
+ if dip_at is not None:
415
+ note += f"; cai aos {num(dip_at)} s"
416
+ return note, round(full_at, 2), dip_at
417
+
418
+
419
+ def voice_band_share(x, sr: int = SR_MEASURE) -> float:
420
+ import numpy as np
421
+
422
+ frame = 4096
423
+ n = len(x) // frame
424
+ if n == 0:
425
+ x = np.pad(x, (0, frame - len(x)))
426
+ n = 1
427
+ frames = x[: n * frame].reshape(n, frame) * np.hanning(frame)
428
+ power = (np.abs(np.fft.rfft(frames, axis=1)) ** 2).sum(axis=0)
429
+ freqs = np.fft.rfftfreq(frame, 1.0 / sr)
430
+ total = power[(freqs >= FULL_BAND[0]) & (freqs <= FULL_BAND[1])].sum()
431
+ voice = power[(freqs >= VOICE_BAND[0]) & (freqs <= VOICE_BAND[1])].sum()
432
+ return round(float(voice / total), 3) if total > 0 else 0.0
433
+
434
+
435
+ def measure(path: Path, name: str | None = None) -> dict:
436
+ """name: the file the person gave (path may be the temporary copy), for the messages."""
437
+ info = probe_audio(path, name)
438
+ x = decode_mono(path, name=name)
439
+ if len(x) == 0 or not (abs(x).max() > 1e-6):
440
+ raise TrilhaError(f"{name or path.name} está em silêncio.")
441
+ note, full_at, _dip_at = energy_profile(x)
442
+ seconds = info["duration"] or len(x) / SR_MEASURE
443
+ lufs = integrated_lufs(path)
444
+ return {"duration_s": round(seconds, 3), "integrated_lufs": round(lufs, 1) if lufs is not None else None,
445
+ "energy": note, "full_at_s": full_at, "voice_band_share": voice_band_share(x)}
446
+
447
+
448
+ # ------------------------------------------------------------------ changing the library
449
+
450
+ def _convert(src: Path, dest: Path, sample_fmt: str) -> None:
451
+ codec = "pcm_s16le" if sample_fmt.startswith(("s16", "u8")) else "pcm_s24le"
452
+ proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", "-i", str(src), "-map", "0:a:0",
453
+ "-vn", "-ac", "2", "-ar", "48000", "-c:a", codec, "-f", "wav", str(dest)],
454
+ capture_output=True, text=True)
455
+ if proc.returncode != 0:
456
+ tail = " ".join((proc.stderr or "").strip().splitlines()[-2:])[:200]
457
+ raise TrilhaError(f"não consegui converter {src.name} para WAV ({tail}).", 1)
458
+
459
+
460
+ def known_origin(src: Path, lib: Path) -> dict:
461
+ """Source, prompt, license and generator facts the skill already wrote for a generated file: the note next to
462
+ a file waiting in <lib>/_recebidas (gen_music --biblioteca) or its entry in <EDIT>/assets/credits.json."""
463
+ try:
464
+ if src.resolve().parent == (lib / INBOX_DIR).resolve():
465
+ note = load_json(src.with_suffix(".json"))
466
+ if isinstance(note, dict):
467
+ facts = ("model", "instrumental", "generated_seconds")
468
+ return {"source": note.get("source"), "prompt": note.get("prompt"), "license": note.get("license"),
469
+ "title": note.get("title"), "extra": {k: note[k] for k in facts if k in note},
470
+ "note_file": src.with_suffix(".json")}
471
+ if src.parent.name == "assets":
472
+ credits = load_json(src.parent / "credits.json")
473
+ for c in credits if isinstance(credits, list) else []:
474
+ if isinstance(c, dict) and c.get("file") == f"assets/{src.name}":
475
+ rules = "; ".join(str(c[k]) for k in ("license", "note") if isinstance(c.get(k), str) and c[k])
476
+ return {"source": c.get("source"), "prompt": c.get("prompt"), "license": rules or None,
477
+ "extra": {k: c[k] for k in ("model", "instrumental") if k in c}}
478
+ except (OSError, ValueError):
479
+ pass
480
+ return {}
481
+
482
+
483
+ def add_track(src, mood_text, *, title: str | None = None, favorite: bool = False, source: str | None = None,
484
+ prompt: str | None = None, license_note: str | None = None, new_mood: bool = False,
485
+ when: str | None = None, lib: Path | None = None, move_source: bool = False,
486
+ extra: dict | None = None) -> dict:
487
+ """Copy (or convert) one file into the library and index it. Returns the new entry (+ "_new_mood")."""
488
+ lib = check_location(lib)
489
+ src = Path(src).expanduser()
490
+ if not src.is_file():
491
+ raise TrilhaError(f"arquivo não encontrado: {src}")
492
+ ext = src.suffix.lower()
493
+ if ext not in AUDIO_EXT + VIDEO_CONVERT:
494
+ raise TrilhaError(f"formato não aceito ({ext or 'sem extensão'}): {src.name}. Use wav, mp3, m4a, aac, ogg, "
495
+ "opus, flac ou aiff (ou um vídeo mp4 ou mov, de onde eu tiro o som).")
496
+ need_ffmpeg()
497
+ info = probe_audio(src)
498
+ if info["duration"] and info["duration"] < MIN_SECONDS:
499
+ raise TrilhaError(f"{src.name} tem só {num(info['duration'], 2)} s. Uma trilha precisa de pelo menos "
500
+ f"{num(MIN_SECONDS)} s (para efeito curto, use sfx.events).")
501
+ index = load_index(lib, required=False) or empty_index()
502
+ mood = find_mood(index, mood_text)
503
+ created = False
504
+ if mood is None:
505
+ mid = slug(mood_text)
506
+ if not mid or not MOOD_ID.match(mid) or mid in RESERVED_MOODS:
507
+ raise TrilhaError(f"nome de humor inválido: '{mood_text}'. Use palavras simples, ex.: --humor triste.")
508
+ close = difflib.get_close_matches(mid, [m["id"] for m in all_moods(index)], n=1, cutoff=0.75)
509
+ if close and not new_mood:
510
+ raise TrilhaError(f"o humor '{mood_text}' não existe. Você quis dizer '{close[0]}'? Para criar um humor "
511
+ "novo com esse nome, repita com --novo-humor.")
512
+ label = " ".join(str(mood_text).split())[:40]
513
+ index["moods"][mid] = {"label": label, "when": " ".join(str(when or "").split())[:120],
514
+ "created_at": now_iso()}
515
+ mood = {"id": mid, "label": label, "when": index["moods"][mid]["when"], "custom": True}
516
+ created = True
517
+ elif when and mood["custom"]:
518
+ info = index["moods"].get(mood["id"])
519
+ if not isinstance(info, dict):
520
+ info = index["moods"][mood["id"]] = {"label": mood["label"], "created_at": now_iso()}
521
+ info["when"] = " ".join(str(when).split())[:120]
522
+
523
+ folder = lib / mood["id"]
524
+ try:
525
+ inside = src.resolve().relative_to(lib.resolve())
526
+ except (ValueError, OSError):
527
+ inside = None
528
+ if inside is not None and inside.parts and inside.parts[0] == TRASH_DIR:
529
+ raise TrilhaError(f"{src.name} está na lixeira da biblioteca. Para trazer de volta, use: "
530
+ f"{skill_cmd('trilhas.py', 'restaurar ID')}")
531
+ src_sha = sha256(src)
532
+ for t in index["tracks"]:
533
+ if t.get("mood") == mood["id"] and src_sha in (t.get("sha256"), t.get("source_sha256")):
534
+ raise TrilhaError(f"essa música já está no humor {mood['id']} como {t['id']} ('{t.get('title')}'). "
535
+ "Nada foi copiado.")
536
+ for t in index["lixeira"]:
537
+ if t.get("mood") == mood["id"] and src_sha in (t.get("sha256"), t.get("source_sha256")):
538
+ back = skill_cmd("trilhas.py", "restaurar " + t["id"])
539
+ raise TrilhaError(f"essa música está na lixeira do humor {mood['id']} como {t['id']} "
540
+ f"('{t.get('title')}'). Nada foi copiado. Para trazer de volta: {back}")
541
+ origin = known_origin(src, lib)
542
+ source = source or origin.get("source")
543
+ title = title or (origin.get("title") if isinstance(origin.get("title"), str) else None)
544
+ prompt = prompt or origin.get("prompt")
545
+ license_note = license_note or origin.get("license")
546
+ extra = {**(origin.get("extra") or {}), **(extra or {})}
547
+ keep = ext in LOSSY_KEEP
548
+ if origin.get("note_file") and keep:
549
+ move_source = True # a generated file waiting in _recebidas: its byte copy moves in
550
+
551
+ track_id = next_id(lib, index, mood["id"])
552
+ dest = folder / f"{track_id}{ext if keep else '.wav'}"
553
+ tmp = folder / f".tmp-{dest.name}"
554
+ new_folder = not folder.exists()
555
+ stored = False
556
+ try:
557
+ folder.mkdir(parents=True, exist_ok=True)
558
+ if keep:
559
+ shutil.copyfile(str(src), str(tmp))
560
+ else:
561
+ _convert(src, tmp, info["sample_fmt"])
562
+ facts = measure(tmp, src.name)
563
+ stored_sha = src_sha if keep else sha256(tmp)
564
+ os.replace(str(tmp), str(dest))
565
+ stored = True
566
+ except OSError as e:
567
+ raise TrilhaError(f"não consegui copiar {src.name} para a biblioteca ({e.__class__.__name__}). Confira o "
568
+ f"espaço e a permissão de {folder}.", 1)
569
+ finally:
570
+ if tmp.exists():
571
+ tmp.unlink()
572
+ if not stored and new_folder:
573
+ try:
574
+ folder.rmdir() # only an empty folder this call created
575
+ except OSError:
576
+ pass
577
+
578
+ if favorite:
579
+ for t in index["tracks"]:
580
+ if t.get("mood") == mood["id"]:
581
+ t["favorite"] = False
582
+ clean_title = " ".join(str(title or "").split())[:80] or \
583
+ " ".join(re.sub(r"[_\-]+", " ", src.stem).split())[:80] or track_id
584
+ entry = {
585
+ "id": track_id, "mood": mood["id"], "file": dest.relative_to(lib).as_posix(), "title": clean_title,
586
+ **facts,
587
+ "source": " ".join(str(source or SOURCE_USER).split())[:120],
588
+ "license": " ".join(str(license_note or LICENSE_USER).split())[:300],
589
+ "prompt": (" ".join(str(prompt).split())[:2000] if prompt else None),
590
+ "added_at": now_iso(), "favorite": bool(favorite), "original_name": src.name,
591
+ "format": "cópia do original" if keep else "WAV 48 kHz estéreo",
592
+ "sha256": stored_sha, "source_sha256": src_sha,
593
+ }
594
+ for key, value in (extra or {}).items():
595
+ entry.setdefault(key, value)
596
+ index["tracks"].append(entry)
597
+ try:
598
+ save_index(lib, index)
599
+ except TrilhaError:
600
+ dest.unlink()
601
+ raise
602
+ if move_source:
603
+ for leftover in (src, origin.get("note_file")):
604
+ try:
605
+ if leftover is not None:
606
+ leftover.unlink()
607
+ except OSError:
608
+ pass
609
+ return {**entry, "_new_mood": created}
610
+
611
+
612
+ def set_favorite(track_id: str, on: bool = True, lib: Path | None = None) -> dict:
613
+ lib = check_location(lib)
614
+ index = load_index(lib)
615
+ entry = find_track(index, track_id)
616
+ if entry is None:
617
+ raise TrilhaError(_no_track(index, track_id))
618
+ for t in index["tracks"]:
619
+ if t.get("mood") == entry["mood"]:
620
+ t["favorite"] = False
621
+ entry["favorite"] = bool(on)
622
+ save_index(lib, index)
623
+ return entry
624
+
625
+
626
+ def _no_track(index: dict, track_id: str) -> str:
627
+ if any(t.get("id") == track_id for t in index.get("lixeira", [])):
628
+ return (f"a trilha {track_id} está na lixeira. Para trazer de volta: "
629
+ f"{skill_cmd('trilhas.py', f'restaurar {track_id}')}")
630
+ return f"não achei a trilha '{track_id}' na biblioteca. Veja os ids com: {skill_cmd('trilhas.py', 'listar')}"
631
+
632
+
633
+ def _free_path(folder: Path, name: str) -> Path:
634
+ path = folder / name
635
+ n = 2
636
+ while path.exists():
637
+ path = folder / f"{Path(name).stem}_{n}{Path(name).suffix}"
638
+ n += 1
639
+ return path
640
+
641
+
642
+ def remove_track(track_id: str, lib: Path | None = None) -> tuple[dict, Path]:
643
+ """Move a track to _lixeira/<mood>/ (never deletes) and keep its entry under "lixeira"."""
644
+ lib = check_location(lib)
645
+ index = load_index(lib)
646
+ entry = find_track(index, track_id)
647
+ if entry is None:
648
+ raise TrilhaError(_no_track(index, track_id))
649
+ src = library_file(lib, entry["file"])
650
+ folder = lib / TRASH_DIR / (entry["mood"] if MOOD_ID.match(entry["mood"]) else "outros")
651
+ folder.mkdir(parents=True, exist_ok=True)
652
+ dest = _free_path(folder, Path(entry["file"].replace("\\", "/")).name)
653
+ if src is not None and src.is_file():
654
+ shutil.move(str(src), str(dest))
655
+ index["tracks"] = [t for t in index["tracks"] if t is not entry]
656
+ was_favorite = bool(entry.get("favorite"))
657
+ entry.update({"file": dest.relative_to(lib).as_posix(), "favorite": False, "was_favorite": was_favorite,
658
+ "removed_at": now_iso()})
659
+ index["lixeira"].append(entry)
660
+ save_index(lib, index)
661
+ return entry, dest
662
+
663
+
664
+ def restore_track(track_id: str, lib: Path | None = None) -> dict:
665
+ lib = check_location(lib)
666
+ index = load_index(lib)
667
+ entry = next((t for t in index["lixeira"] if t.get("id") == track_id), None)
668
+ if entry is None:
669
+ raise TrilhaError(f"a trilha '{track_id}' não está na lixeira.")
670
+ src = library_file(lib, entry["file"])
671
+ if src is None or not src.is_file():
672
+ raise TrilhaError(f"o arquivo da trilha {track_id} sumiu da lixeira ({lib / TRASH_DIR}).")
673
+ if not MOOD_ID.match(entry["mood"]):
674
+ raise TrilhaError(f"a trilha {track_id} tem um humor inválido no índice ('{entry['mood']}').")
675
+ folder = lib / entry["mood"]
676
+ folder.mkdir(parents=True, exist_ok=True)
677
+ dest = _free_path(folder, src.name)
678
+ shutil.move(str(src), str(dest))
679
+ index["lixeira"] = [t for t in index["lixeira"] if t is not entry]
680
+ for key in ("removed_at", "was_favorite"):
681
+ entry.pop(key, None)
682
+ entry["file"] = dest.relative_to(lib).as_posix()
683
+ index["tracks"].append(entry)
684
+ save_index(lib, index)
685
+ return entry
686
+
687
+
688
+ # ------------------------------------------------------------------ choosing
689
+
690
+ def _score(t: dict, seconds: float | None) -> tuple:
691
+ length = float(t.get("duration_s") or 0.0)
692
+ voice = float(t.get("voice_band_share") if t.get("voice_band_share") is not None else 0.5)
693
+ full_at = float(t.get("full_at_s") or 0.0)
694
+ if seconds:
695
+ short = max(0.0, seconds + FADE_ROOM_S - length) / seconds
696
+ late = min(1.0, max(0.0, full_at - 1.0) / seconds)
697
+ closeness = abs(length - (seconds + 2.0))
698
+ else:
699
+ short, late, closeness = 0.0, min(1.0, max(0.0, full_at - 1.0) / 30.0), 0.0
700
+ return (round(3.0 * short + late + voice, 6), closeness, str(t["id"]))
701
+
702
+
703
+ def choose(lib: Path, index: dict, mood: dict, seconds: float | None = None, track_id: str | None = None,
704
+ exclude: tuple = ()) -> tuple[dict, str]:
705
+ """(track, why) for a mood: the asked track, else the favorite, else the best fit."""
706
+ if track_id:
707
+ entry = find_track(index, track_id)
708
+ if entry is None:
709
+ raise TrilhaError(_no_track(index, track_id))
710
+ if not _has_file(lib, entry):
711
+ raise TrilhaError(f"o arquivo da trilha {track_id} sumiu da biblioteca ({lib}).")
712
+ other = f" (ela é do humor {entry['mood']})" if entry["mood"] != mood["id"] else ""
713
+ return entry, f"foi a trilha pedida ({track_id}){other}"
714
+ all_tracks = mood_tracks(lib, index, mood["id"])
715
+ tracks = [t for t in all_tracks if t["id"] not in exclude]
716
+ if not tracks:
717
+ if all_tracks:
718
+ raise TrilhaError(f"o humor '{mood['id']}' só tem a trilha que esta edição já usa "
719
+ f"({', '.join(exclude)}). {fill_hint(mood['id'])}.")
720
+ missing = [t for t in index.get("tracks", []) if t.get("mood") == mood["id"]]
721
+ gone = f" ({len(missing)} com o arquivo sumido)" if missing else ""
722
+ raise TrilhaError(f"o humor '{mood['id']}' ({mood['label']}) ainda não tem trilha na biblioteca{gone} "
723
+ f"({lib}). {fill_hint(mood['id'])}.")
724
+ favorite = next((t for t in tracks if t.get("favorite")), None)
725
+ if favorite:
726
+ return favorite, f"é a favorita do humor {mood['label']}"
727
+ best = min(tracks, key=lambda t: _score(t, seconds))
728
+ parts = []
729
+ if len(tracks) == 1:
730
+ head = f"é a única trilha do humor {mood['label']}"
731
+ else:
732
+ head = f"é a que encaixa melhor entre {len(tracks)} trilhas do humor {mood['label']} (nenhuma é favorita)"
733
+ length = float(best.get("duration_s") or 0.0)
734
+ if seconds:
735
+ if length >= seconds + FADE_ROOM_S:
736
+ parts.append(f"dura {dur(length)} e cobre os {dur(seconds)} do corte sem repetir")
737
+ else:
738
+ parts.append(f"dura {dur(length)}, menos que o corte ({dur(seconds)}), então vai repetir")
739
+ share = best.get("voice_band_share")
740
+ if share is not None:
741
+ lowest = len(tracks) > 1 and all(share <= (t.get("voice_band_share") if t.get("voice_band_share") is not None
742
+ else 1.0) for t in tracks)
743
+ parts.append(f"{round(share * 100)}% da energia na faixa da voz" + (" (a menor)" if lowest else ""))
744
+ if best.get("energy"):
745
+ parts.append(str(best["energy"]))
746
+ return best, head + (": " + ", ".join(parts) if parts else "")
747
+
748
+
749
+ # ------------------------------------------------------------------ edit folder
750
+
751
+ def load_lock(edit: Path) -> tuple[dict, str | None]:
752
+ """(lock, warning). A broken lock counts as empty (the choice is made again)."""
753
+ path = edit / "assets" / LOCK_NAME
754
+ empty = {"version": 1, "moods": {}, "copies": [], "hashes": {}}
755
+ if not path.is_file():
756
+ return empty, None
757
+ try:
758
+ data = json.loads(path.read_text(encoding="utf-8"))
759
+ if not isinstance(data, dict) or not isinstance(data.get("moods", {}), dict):
760
+ raise ValueError("formato")
761
+ except (OSError, ValueError):
762
+ return empty, f"assets/{LOCK_NAME} estava ilegível: a trilha foi escolhida de novo"
763
+ data.setdefault("version", 1)
764
+ data.setdefault("moods", {})
765
+ if not isinstance(data.get("copies"), list):
766
+ data["copies"] = []
767
+ if not isinstance(data.get("hashes"), dict):
768
+ data["hashes"] = {}
769
+ return data, None
770
+
771
+
772
+ def copy_to_edit(lib: Path, entry: dict, edit: Path, mood_id: str, why: str) -> dict:
773
+ """Copy the chosen library file to <EDIT>/assets/trilha_<mood>.<ext> and record it in the lock."""
774
+ src = library_file(lib, entry.get("file"))
775
+ if src is None or not src.is_file():
776
+ raise TrilhaError(f"o arquivo da trilha {entry.get('id')} sumiu da biblioteca ({lib}).")
777
+ ext = src.suffix.lower()
778
+ assets = edit / "assets"
779
+ assets.mkdir(parents=True, exist_ok=True)
780
+ lock, _warn = load_lock(edit)
781
+ digest = sha256(src)
782
+ records = [r for r in lock["moods"].values() if isinstance(r, dict)]
783
+ target = None
784
+ for n in range(1, 100):
785
+ name = f"trilha_{mood_id}{ext}" if n == 1 else f"trilha_{mood_id}_{n}{ext}"
786
+ path = assets / name
787
+ rel = f"assets/{name}"
788
+ if not path.exists():
789
+ target = path
790
+ break
791
+ if not path.is_file():
792
+ continue
793
+ current = sha256(path)
794
+ # rewritten only when it still holds what this helper copied (a file the person put there stays)
795
+ if current == digest or lock["hashes"].get(rel) == current or \
796
+ any(r.get("file") == rel and r.get("sha256") == current for r in records):
797
+ target = path
798
+ break
799
+ if target is None:
800
+ raise TrilhaError(f"assets/ já tem arquivos demais com o nome trilha_{mood_id}. Nada foi copiado.", 1)
801
+ rel = f"assets/{target.name}"
802
+ if not (target.is_file() and sha256(target) == digest):
803
+ tmp = assets / f".tmp-{target.name}"
804
+ try:
805
+ shutil.copyfile(str(src), str(tmp))
806
+ os.replace(str(tmp), str(target))
807
+ except OSError as e:
808
+ raise TrilhaError(f"não consegui copiar a trilha para {assets} ({e.__class__.__name__}).", 1)
809
+ finally:
810
+ if tmp.exists():
811
+ tmp.unlink()
812
+ record = {"track_id": entry["id"], "mood": mood_id, "title": entry.get("title"), "file": rel, "sha256": digest,
813
+ "library_file": entry["file"], "duration_s": entry.get("duration_s"),
814
+ "source": entry.get("source"), "license": entry.get("license"), "why": why, "chosen_at": now_iso()}
815
+ lock["moods"][mood_id] = record
816
+ if rel not in lock["copies"]:
817
+ lock["copies"].append(rel)
818
+ lock["hashes"][rel] = digest
819
+ save_json(assets / LOCK_NAME, lock)
820
+ return record
821
+
822
+
823
+ def cut_seconds(edit: Path) -> float | None:
824
+ try:
825
+ value = float((load_json(edit / "cut_timeline.json") or {}).get("duration") or 0)
826
+ except (OSError, ValueError, TypeError, AttributeError):
827
+ return None
828
+ return value if value > 0 and math.isfinite(value) else None
829
+
830
+
831
+ def _index_for(lib: Path, mood_text) -> dict:
832
+ index = load_index(lib, required=False)
833
+ if index is None:
834
+ raise TrilhaError(missing_library_message(lib, slug(mood_text) or "triste"))
835
+ return index
836
+
837
+
838
+ def pick_for_edit(edit: Path, mood_text, seconds: float | None = None, track_id: str | None = None,
839
+ change: bool = False, lib: Path | None = None) -> dict:
840
+ """The `escolher` command: always choose again (or the asked track), copy and record."""
841
+ lib = check_location(lib)
842
+ index = _index_for(lib, mood_text)
843
+ mood = find_mood(index, mood_text)
844
+ if mood is None:
845
+ raise TrilhaError(unknown_mood_message(mood_text, index))
846
+ lock, _warn = load_lock(edit)
847
+ exclude = ()
848
+ if change and not track_id:
849
+ current = (lock["moods"].get(mood["id"]) or {}).get("track_id")
850
+ exclude = (current,) if current else ()
851
+ entry, why = choose(lib, index, mood, seconds, track_id, exclude)
852
+ if exclude:
853
+ why = f"troquei a {exclude[0]}; {why}"
854
+ record = copy_to_edit(lib, entry, edit, mood["id"], why)
855
+ return {**record, "mood_label": mood["label"], "from": "biblioteca"}
856
+
857
+
858
+ def resolve_for_edit(edit: Path, mood_text, seconds: float | None = None, track_id: str | None = None,
859
+ lib: Path | None = None) -> dict:
860
+ """compose_final's music.mood: the recorded choice when its file is intact, else choose and record."""
861
+ mood_id = slug(mood_text)
862
+ if not mood_id:
863
+ raise TrilhaError("music.mood está vazio. Use um humor, ex.: \"mood\": \"triste\".")
864
+ lock, warn = load_lock(edit)
865
+ rec = lock["moods"].get(mood_id)
866
+ if rec and (not track_id or track_id == rec.get("track_id")):
867
+ path = edit / str(rec.get("file", ""))
868
+ if path.is_file() and sha256(path) == rec.get("sha256"):
869
+ out = {**rec, "from": "escolha anterior"}
870
+ return out
871
+ lib = check_location(lib)
872
+ index = _index_for(lib, mood_text)
873
+ mood = find_mood(index, mood_text)
874
+ if mood is None:
875
+ raise TrilhaError(unknown_mood_message(mood_text, index))
876
+ entry, why = choose(lib, index, mood, seconds, track_id)
877
+ record = copy_to_edit(lib, entry, edit, mood["id"], why)
878
+ out = {**record, "mood_label": mood["label"], "from": "biblioteca"}
879
+ if warn:
880
+ out["warning"] = warn
881
+ return out
882
+
883
+
884
+ def edit_music_files(edit: Path) -> list[str]:
885
+ """Music files already in <EDIT>/assets that the person could use as they are (not the library copies)."""
886
+ assets = edit / "assets"
887
+ if not assets.is_dir():
888
+ return []
889
+ lock, _warn = load_lock(edit)
890
+ ours = set(lock.get("copies") or []) | {str(r.get("file")) for r in lock["moods"].values() if isinstance(r, dict)}
891
+ out = []
892
+ for path in sorted(assets.iterdir()):
893
+ rel = f"assets/{path.name}"
894
+ if not path.is_file() or path.name.startswith((".", "sfx_")) or rel in ours:
895
+ continue
896
+ if path.suffix.lower() in AUDIO_EXT:
897
+ out.append(rel)
898
+ return out
899
+
900
+
901
+ # ------------------------------------------------------------------ read-only views
902
+
903
+ def moods_summary(lib: Path | None = None) -> dict:
904
+ """What the preview shows: moods that have tracks. Never creates or changes anything."""
905
+ try:
906
+ lib = check_location(lib)
907
+ except TrilhaError as e:
908
+ return {"exists": False, "error": str(e), "moods": []}
909
+ try:
910
+ index = load_index(lib, required=False)
911
+ except TrilhaError as e:
912
+ return {"exists": True, "error": str(e), "moods": []}
913
+ if index is None:
914
+ return {"exists": False, "moods": []}
915
+ moods = []
916
+ for m in all_moods(index):
917
+ tracks = mood_tracks(lib, index, m["id"])
918
+ if not tracks:
919
+ continue
920
+ fav = next((t for t in tracks if t.get("favorite")), None)
921
+ moods.append({"id": m["id"], "label": m["label"], "when": m["when"], "count": len(tracks),
922
+ "favorite_id": fav["id"] if fav else None, "favorite_title": fav.get("title") if fav else None,
923
+ "custom": m["custom"]})
924
+ return {"exists": True, "moods": moods}
925
+
926
+
927
+ def _say(text: str = "") -> None:
928
+ try:
929
+ print(text, flush=True)
930
+ except UnicodeError:
931
+ sys.stdout.buffer.write((text + "\n").encode("utf-8", "replace"))
932
+
933
+
934
+ def track_line(t: dict, lib: Path) -> str:
935
+ bits = [dur(t.get("duration_s") or 0)]
936
+ if t.get("integrated_lufs") is not None:
937
+ bits.append(f"{num(t['integrated_lufs'])} LUFS")
938
+ if t.get("energy"):
939
+ bits.append(str(t["energy"]))
940
+ if t.get("voice_band_share") is not None:
941
+ bits.append(f"voz {round(t['voice_band_share'] * 100)}%")
942
+ bits.append(str(t.get("source") or SOURCE_USER))
943
+ star = "★" if t.get("favorite") else " "
944
+ gone = "" if _has_file(lib, t) else " [ARQUIVO SUMIU]"
945
+ return f" {star} {t['id']} \"{t.get('title')}\" · " + " · ".join(bits) + gone
946
+
947
+
948
+ def cmd_listar(args) -> int:
949
+ lib = check_location()
950
+ index = load_index(lib, required=False)
951
+ if args.json:
952
+ _say(json.dumps({"library": str(lib), "index": index}, ensure_ascii=False, indent=2))
953
+ return 0
954
+ if index is None:
955
+ _say(f"A biblioteca de trilhas ainda está vazia ({lib}).")
956
+ _say(fill_hint("triste") + ".")
957
+ return 0
958
+ only = None
959
+ if args.humor:
960
+ only = find_mood(index, args.humor)
961
+ if only is None:
962
+ raise TrilhaError(unknown_mood_message(args.humor, index))
963
+ _say(f"Biblioteca de trilhas: {lib}")
964
+ empty = []
965
+ for m in all_moods(index):
966
+ if only and m["id"] != only["id"]:
967
+ continue
968
+ tracks = mood_tracks(lib, index, m["id"], existing_only=False)
969
+ if not tracks:
970
+ empty.append(m["id"])
971
+ continue
972
+ when = f" · {m['when']}" if m["when"] else ""
973
+ label = "trilha" if len(tracks) == 1 else "trilhas"
974
+ _say(f"\n{m['label']} ({m['id']}){when} · {len(tracks)} {label}")
975
+ for t in tracks:
976
+ _say(track_line(t, lib))
977
+ if empty:
978
+ _say(f"\nSem trilha ainda: {', '.join(empty)}.")
979
+ _say(fill_hint(empty[0]) + ".")
980
+ trash = index.get("lixeira") or []
981
+ if trash and not only:
982
+ _say(f"\nNa lixeira ({lib / TRASH_DIR}): {', '.join(t['id'] for t in trash)}")
983
+ return 0
984
+
985
+
986
+ def cmd_humores(args) -> int:
987
+ lib = check_location()
988
+ index = load_index(lib, required=False)
989
+ rows = []
990
+ for m in all_moods(index):
991
+ tracks = mood_tracks(lib, index, m["id"]) if index else []
992
+ fav = next((t for t in tracks if t.get("favorite")), None)
993
+ rows.append({**m, "count": len(tracks), "favorite_id": fav["id"] if fav else None,
994
+ "favorite_title": fav.get("title") if fav else None})
995
+ beats = load_presets().get("beats") or {}
996
+ if args.json:
997
+ _say(json.dumps({"library": str(lib), "moods": rows, "beats": beats}, ensure_ascii=False, indent=2))
998
+ return 0
999
+ _say(f"Humores da biblioteca ({lib}):")
1000
+ for r in rows:
1001
+ count = "vazio" if not r["count"] else f"{r['count']} {'trilha' if r['count'] == 1 else 'trilhas'}"
1002
+ fav = f" (favorita: \"{r['favorite_title']}\")" if r["favorite_title"] else ""
1003
+ tag = " [seu]" if r["custom"] else ""
1004
+ _say(f" {r['id']:<12} {r['label']:<12} {r['when'] or '-':<45} {count}{fav}{tag}")
1005
+ if beats:
1006
+ labels = {m["id"]: m["label"].lower() for m in rows}
1007
+ tips = [f"{beat} {' ou '.join(labels.get(i, i) for i in ids)}" for beat, ids in beats.items()]
1008
+ _say("Sugestão pelo trecho do anúncio: " + " · ".join(tips))
1009
+ return 0
1010
+
1011
+
1012
+ def cmd_adicionar(args) -> int:
1013
+ entry = add_track(args.arquivo, args.humor, title=args.titulo, favorite=args.favorita, source=args.fonte,
1014
+ prompt=args.prompt, license_note=args.licenca, new_mood=args.novo_humor, when=args.quando)
1015
+ lib = library_dir()
1016
+ if entry.get("_new_mood"):
1017
+ _say(f"humor novo criado: {entry['mood']}")
1018
+ _say(f"ok: {entry['id']} \"{entry['title']}\" entrou no humor {entry['mood']}")
1019
+ _say(f" arquivo: {lib / entry['file']} ({entry['format']})")
1020
+ lufs = f"{num(entry['integrated_lufs'])} LUFS" if entry.get("integrated_lufs") is not None else "volume não medido"
1021
+ _say(f" {dur(entry['duration_s'])} · {lufs} · {entry['energy']} · "
1022
+ f"{round(entry['voice_band_share'] * 100)}% da energia na faixa da voz")
1023
+ _say(f" origem: {entry['source']} · uso: {entry['license']}")
1024
+ _say(f" favorita: {'sim' if entry['favorite'] else 'não'}")
1025
+ return 0
1026
+
1027
+
1028
+ def cmd_favorita(args) -> int:
1029
+ entry = set_favorite(args.id, on=not args.tirar)
1030
+ if entry["favorite"]:
1031
+ _say(f"ok: {entry['id']} \"{entry.get('title')}\" é a favorita do humor {entry['mood']}")
1032
+ else:
1033
+ _say(f"ok: o humor {entry['mood']} ficou sem favorita (a escolha vai pelo encaixe no corte)")
1034
+ return 0
1035
+
1036
+
1037
+ def cmd_remover(args) -> int:
1038
+ entry, dest = remove_track(args.id)
1039
+ track_id = entry["id"]
1040
+ _say(f"ok: {track_id} \"{entry.get('title')}\" foi para a lixeira ({dest}). Nada foi apagado.")
1041
+ _say("Para trazer de volta: " + skill_cmd("trilhas.py", f"restaurar {track_id}"))
1042
+ return 0
1043
+
1044
+
1045
+ def cmd_restaurar(args) -> int:
1046
+ entry = restore_track(args.id)
1047
+ _say(f"ok: {entry['id']} \"{entry.get('title')}\" voltou para o humor {entry['mood']}")
1048
+ return 0
1049
+
1050
+
1051
+ def cmd_escolher(args) -> int:
1052
+ edit = Path(args.edit_dir).expanduser().resolve()
1053
+ if not edit.is_dir():
1054
+ raise TrilhaError(f"pasta edit não encontrada: {edit}")
1055
+ seconds = args.duracao
1056
+ origin = "pedido em --duracao"
1057
+ if seconds is None:
1058
+ seconds = cut_seconds(edit)
1059
+ origin = "duração do corte" if seconds else "sem duração do corte"
1060
+ elif not (math.isfinite(seconds) and seconds > 0):
1061
+ raise TrilhaError("--duracao precisa ser um número maior que zero (ex.: 32)")
1062
+ rec = pick_for_edit(edit, args.humor, seconds, args.trilha, args.trocar)
1063
+ _say(f"trilha: {rec['track_id']} \"{rec.get('title')}\" (humor {rec['mood']})")
1064
+ _say(f"arquivo: {edit / rec['file']}")
1065
+ _say(f"por quê: {rec['why']}" + (f" ({origin}: {dur(seconds)})" if seconds else ""))
1066
+ _say(f"origem: {rec.get('source')} · uso: {rec.get('license')}")
1067
+ _say(f'no edit_data.json: "music": {{"enabled": true, "mood": "{rec["mood"]}", "volume_db": -15, '
1068
+ '"duck": true, "fade_out": 1.5}')
1069
+ return 0
1070
+
1071
+
1072
+ def cmd_onde(_args) -> int:
1073
+ lib = check_location()
1074
+ state = "existe" if (lib / INDEX_NAME).is_file() else "ainda não existe (nasce com a primeira música)"
1075
+ _say(f"{lib} ({state})")
1076
+ return 0
1077
+
1078
+
1079
+ def build_parser() -> argparse.ArgumentParser:
1080
+ ap = argparse.ArgumentParser(prog="trilhas.py", description="Biblioteca pessoal de trilhas por humor "
1081
+ "(fica fora da skill, em ~/.editor-video/trilhas).")
1082
+ sub = ap.add_subparsers(dest="cmd", metavar="comando")
1083
+ sub.required = True
1084
+ p = sub.add_parser("onde", help="mostra onde fica a biblioteca")
1085
+ p.set_defaults(fn=cmd_onde)
1086
+ p = sub.add_parser("humores", help="lista os humores, quando usar e quantas trilhas cada um tem")
1087
+ p.add_argument("--json", action="store_true")
1088
+ p.set_defaults(fn=cmd_humores)
1089
+ p = sub.add_parser("listar", help="lista as trilhas por humor, a favorita primeiro")
1090
+ p.add_argument("--humor", help="só este humor")
1091
+ p.add_argument("--json", action="store_true")
1092
+ p.set_defaults(fn=cmd_listar)
1093
+ p = sub.add_parser("adicionar", help="copia um arquivo de música para a biblioteca")
1094
+ p.add_argument("arquivo", help="arquivo de música (wav, mp3, m4a, aac, ogg, opus, flac, aiff) ou vídeo")
1095
+ p.add_argument("--humor", required=True, help="humor (ex.: triste). Nome novo cria um humor seu")
1096
+ p.add_argument("--titulo", help="nome para mostrar (padrão: o nome do arquivo)")
1097
+ p.add_argument("--favorita", action="store_true", help="vira a favorita do humor")
1098
+ p.add_argument("--fonte", help=f"de onde veio (padrão: {SOURCE_USER})")
1099
+ p.add_argument("--prompt", help="pedido usado para gerar, se foi feita com IA")
1100
+ p.add_argument("--licenca", help="nota de licença ou de uso")
1101
+ p.add_argument("--novo-humor", action="store_true", help="cria o humor mesmo com nome parecido com outro")
1102
+ p.add_argument("--quando", help="quando usar (só para humor seu)")
1103
+ p.set_defaults(fn=cmd_adicionar)
1104
+ p = sub.add_parser("favorita", help="marca a favorita do humor")
1105
+ p.add_argument("id")
1106
+ p.add_argument("--tirar", action="store_true", help="tira a marca de favorita")
1107
+ p.set_defaults(fn=cmd_favorita)
1108
+ p = sub.add_parser("remover", help="manda a trilha para a lixeira (não apaga)")
1109
+ p.add_argument("id")
1110
+ p.set_defaults(fn=cmd_remover)
1111
+ p = sub.add_parser("restaurar", help="traz uma trilha de volta da lixeira")
1112
+ p.add_argument("id")
1113
+ p.set_defaults(fn=cmd_restaurar)
1114
+ p = sub.add_parser("escolher", help="escolhe a trilha de um humor para uma edição e copia para assets/")
1115
+ p.add_argument("--humor", required=True)
1116
+ p.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
1117
+ p.add_argument("--duracao", type=float, default=None, help="duração do corte em segundos (padrão: cut_timeline.json)")
1118
+ p.add_argument("--trilha", help="usa esta trilha (id) em vez de escolher")
1119
+ p.add_argument("--trocar", action="store_true", help="escolhe outra, diferente da que a edição usa agora")
1120
+ p.set_defaults(fn=cmd_escolher)
1121
+ return ap
1122
+
1123
+
1124
+ def main(argv: list[str] | None = None) -> int:
1125
+ args = build_parser().parse_args(argv)
1126
+ try:
1127
+ return args.fn(args)
1128
+ except TrilhaError as e:
1129
+ print(f"erro: {e}", file=sys.stderr, flush=True)
1130
+ return e.code
1131
+ except KeyboardInterrupt:
1132
+ print("interrompido", file=sys.stderr)
1133
+ return 130
1134
+
1135
+
1136
+ def utf8_console() -> None:
1137
+ for stream in (sys.stdout, sys.stderr):
1138
+ try:
1139
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
1140
+ except (AttributeError, ValueError, OSError):
1141
+ pass
1142
+
1143
+
1144
+ if __name__ == "__main__":
1145
+ utf8_console()
1146
+ sys.exit(main())