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.
- package/LICENSES/edit-ia-pro.txt +32 -0
- package/LICENSES/fontes-OFL.txt +476 -0
- package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
- package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
- package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
- package/LICENSES/three-MIT.txt +23 -0
- package/LICENSES/video-use-MIT.txt +24 -0
- package/LICENSES/woven-sfx-CC0.txt +16 -0
- package/LICENSES/yunet-MIT.txt +21 -0
- package/README.md +2 -2
- package/bin/primocode.js +11 -2
- package/editor/LICENSE.md +11 -0
- package/editor/README.md +321 -0
- package/editor/SKILL.md +307 -0
- package/editor/THIRD_PARTY_NOTICES.md +228 -0
- package/editor/assets/baixar.json +591 -0
- package/editor/assets/fonts/anton/OFL.txt +93 -0
- package/editor/assets/fonts/caveat/OFL.txt +93 -0
- package/editor/assets/fonts/figtree/OFL.txt +93 -0
- package/editor/assets/fonts/fonts.json +10 -0
- package/editor/assets/fonts/montserrat/OFL.txt +93 -0
- package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
- package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
- package/editor/assets/models/YUNET_LICENSE.txt +21 -0
- package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
- package/editor/assets/sfx/licencas/cash.txt +76 -0
- package/editor/assets/sfx/licencas/charge.txt +31 -0
- package/editor/assets/sfx/licencas/click.txt +46 -0
- package/editor/assets/sfx/licencas/coin.txt +50 -0
- package/editor/assets/sfx/licencas/count.txt +47 -0
- package/editor/assets/sfx/licencas/data.txt +31 -0
- package/editor/assets/sfx/licencas/error.txt +32 -0
- package/editor/assets/sfx/licencas/flash.txt +31 -0
- package/editor/assets/sfx/licencas/gears.txt +82 -0
- package/editor/assets/sfx/licencas/keyboard.txt +48 -0
- package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
- package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
- package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
- package/editor/assets/sfx/licencas/notification.txt +54 -0
- package/editor/assets/sfx/licencas/pop.txt +38 -0
- package/editor/assets/sfx/licencas/rewind.txt +32 -0
- package/editor/assets/sfx/licencas/rise.txt +46 -0
- package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
- package/editor/assets/sfx/licencas/snap.txt +50 -0
- package/editor/assets/sfx/licencas/success.txt +40 -0
- package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
- package/editor/assets/sfx/licencas/tick.txt +14 -0
- package/editor/assets/sfx/licencas/whoosh.txt +47 -0
- package/editor/assets/sfx/licencas/wind.txt +31 -0
- package/editor/assets/sfx/sfx.json +832 -0
- package/editor/assets/sfx/sfx_sinteticos.json +317 -0
- package/editor/assets/sfx/sfx_woven.json +387 -0
- package/editor/assets/sfx/woven/LICENCA.md +16 -0
- package/editor/fichas/briefing-do-video.md +45 -0
- package/editor/fichas/conferencia-da-amostra.md +56 -0
- package/editor/fichas/matriz-de-ganchos.csv +3 -0
- package/editor/fichas/padrao.json +16 -0
- package/editor/fichas/planilha-de-cenas.csv +4 -0
- package/editor/fichas/planilha-de-versoes.csv +4 -0
- package/editor/fichas/plano-em-blocos.md +32 -0
- package/editor/fichas/registro-de-materiais.json +27 -0
- package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
- package/editor/helpers/bordas_precisas.py +272 -0
- package/editor/helpers/camera_viva.py +426 -0
- package/editor/helpers/captions.py +601 -0
- package/editor/helpers/common.py +308 -0
- package/editor/helpers/compose_final.py +3555 -0
- package/editor/helpers/conferir_corte.py +234 -0
- package/editor/helpers/conferir_dados.py +405 -0
- package/editor/helpers/contact_sheet.py +282 -0
- package/editor/helpers/corte_por_onda.py +433 -0
- package/editor/helpers/corte_seco.py +595 -0
- package/editor/helpers/denoise.py +971 -0
- package/editor/helpers/efeitos.py +408 -0
- package/editor/helpers/efeitos_gerar.py +159 -0
- package/editor/helpers/elevenlabs_api.py +442 -0
- package/editor/helpers/face_layout.py +570 -0
- package/editor/helpers/fetch_images.py +423 -0
- package/editor/helpers/ganho_por_trecho.py +214 -0
- package/editor/helpers/gen_image.py +312 -0
- package/editor/helpers/gen_music.py +316 -0
- package/editor/helpers/gen_sfx.py +148 -0
- package/editor/helpers/grade.py +307 -0
- package/editor/helpers/icloud.py +119 -0
- package/editor/helpers/make_sfx.py +940 -0
- package/editor/helpers/matte.py +1342 -0
- package/editor/helpers/medir_referencia.py +2232 -0
- package/editor/helpers/mixar_trilha.py +354 -0
- package/editor/helpers/montar_corte.py +295 -0
- package/editor/helpers/motion/__init__.py +13 -0
- package/editor/helpers/motion/engine.py +1915 -0
- package/editor/helpers/motion/registry.py +510 -0
- package/editor/helpers/motion/render.py +665 -0
- package/editor/helpers/motion/templates/barra_progresso.py +370 -0
- package/editor/helpers/motion/templates/carimbo.py +132 -0
- package/editor/helpers/motion/templates/checklist.py +271 -0
- package/editor/helpers/motion/templates/comentario_cta.py +380 -0
- package/editor/helpers/motion/templates/contador.py +222 -0
- package/editor/helpers/motion/templates/destaque.py +263 -0
- package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
- package/editor/helpers/motion/templates/grafico_barras.py +510 -0
- package/editor/helpers/motion/templates/grafico_linha.py +462 -0
- package/editor/helpers/motion/templates/notificacao.py +266 -0
- package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
- package/editor/helpers/motion/templates/passos.py +321 -0
- package/editor/helpers/motion/templates/riscado.py +298 -0
- package/editor/helpers/motion/templates/selo_garantia.py +150 -0
- package/editor/helpers/motion_render.py +241 -0
- package/editor/helpers/motion_sugerir.py +961 -0
- package/editor/helpers/noise_check.py +134 -0
- package/editor/helpers/pack_transcripts.py +223 -0
- package/editor/helpers/palavras_na_onda.py +176 -0
- package/editor/helpers/plano_sfx.py +839 -0
- package/editor/helpers/portao.py +427 -0
- package/editor/helpers/preview_server.py +1177 -0
- package/editor/helpers/rastrear_rosto.py +298 -0
- package/editor/helpers/render_cut.py +1256 -0
- package/editor/helpers/respiros.py +89 -0
- package/editor/helpers/sincronizar_cameras.py +571 -0
- package/editor/helpers/speech_regions.py +423 -0
- package/editor/helpers/style_catalog.json +231 -0
- package/editor/helpers/style_samples.py +246 -0
- package/editor/helpers/tela_verde.py +2113 -0
- package/editor/helpers/text_render.py +2059 -0
- package/editor/helpers/texto_na_tela.py +366 -0
- package/editor/helpers/timeline_view.py +292 -0
- package/editor/helpers/tira_de_quadros.py +113 -0
- package/editor/helpers/transcribe.py +734 -0
- package/editor/helpers/transcribe_batch.py +129 -0
- package/editor/helpers/trilhas.py +1146 -0
- package/editor/helpers/trilhas_humores.json +66 -0
- package/editor/helpers/valores.py +187 -0
- package/editor/helpers/verify_cut.py +820 -0
- package/editor/helpers/voice_levels.py +233 -0
- package/editor/helpers/voz_check.py +386 -0
- package/editor/helpers/voz_limpa.py +81 -0
- package/editor/helpers/watch_preview.py +372 -0
- package/editor/modelo-projeto/LEIA.md +402 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
- package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
- package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
- package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
- package/editor/modelo-projeto/assets/three/LICENSE +21 -0
- package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
- package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
- package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
- package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
- package/editor/modelo-projeto/build.py +4828 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
- package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
- package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
- package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
- package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
- package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
- package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
- package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
- package/editor/modelo-projeto/hyperframes.json +12 -0
- package/editor/modelo-projeto/package.json +11 -0
- package/editor/modelo-projeto/projeto.json +16 -0
- package/editor/preview/app.css +1009 -0
- package/editor/preview/app.js +2122 -0
- package/editor/preview/index.html +307 -0
- package/editor/pyproject.toml +23 -0
- package/editor/references/3d.md +236 -0
- package/editor/references/animacao-catalogo.md +672 -0
- package/editor/references/animacao.md +498 -0
- package/editor/references/comandos.md +92 -0
- package/editor/references/corte-seco-como-funciona.md +50 -0
- package/editor/references/corte-seco.md +88 -0
- package/editor/references/corte.md +290 -0
- package/editor/references/direcao-por-blocos.md +218 -0
- package/editor/references/enquadramento.md +654 -0
- package/editor/references/estilos-prontos.md +355 -0
- package/editor/references/estudio.md +100 -0
- package/editor/references/exemplo-eventos.json +67 -0
- package/editor/references/lettering.md +252 -0
- package/editor/references/modelos.md +494 -0
- package/editor/references/phase2.md +1007 -0
- package/editor/references/referencia-aplicar.md +203 -0
- package/editor/references/referencia-medir.md +375 -0
- package/editor/references/revisao.md +335 -0
- package/editor/references/som-e-trilha.md +146 -0
- package/editor/references/tabela-de-sons.md +130 -0
- package/editor/references/variacoes.md +234 -0
- package/editor/uv.lock +1141 -0
- package/lib/act.js +36 -8
- package/lib/catalogo.js +4 -0
- package/lib/chamada-torta.js +81 -0
- package/lib/editor-pro.js +786 -0
- package/lib/entrega.js +58 -0
- package/lib/estudio/conferencia-visual.js +76 -0
- package/lib/estudio/diretor.js +6 -1
- package/lib/estudio/exportar.js +3 -1
- package/lib/estudio/gosto.js +45 -4
- package/lib/estudio/index.js +8 -5
- package/lib/estudio/kit-ae/base.jsx +119 -13
- package/lib/estudio/kit-ae/cenas.jsx +47 -15
- package/lib/estudio/kit-ae/guarda.jsx +274 -0
- package/lib/estudio/kit-ae/primitivas.jsx +180 -24
- package/lib/estudio/maquina-diretor.js +61 -7
- package/lib/estudio/maquina-foto.js +177 -0
- package/lib/estudio/maquina-guarda.js +207 -0
- package/lib/estudio/maquina-layout.js +22 -1
- package/lib/estudio/maquina.js +60 -11
- package/lib/estudio/referencias.js +1 -1
- package/lib/estudio/visao/visao.swift +97 -0
- package/lib/estudio/visao.js +60 -0
- package/lib/local/cerebro.json +192 -6
- package/lib/local/motor.js +5 -2
- package/lib/nuvem.js +11 -1
- package/lib/passagem.js +50 -0
- package/lib/perigo.js +19 -11
- package/lib/protecao.js +134 -0
- package/lib/titulo-conversa.js +46 -0
- package/lib/tools.js +3 -0
- package/lib/ui.js +1 -1
- package/package.json +3 -2
- package/skills/editar-meu-video/SKILL.md +6 -2
- package/skills/editar-video/SKILL.md +113 -0
- package/studio/pessoa.py +52 -0
- package/voz/primo_voz/app.py +43 -4
- package/voz/primo_voz/chat.html +17 -6
- 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())
|