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,81 @@
1
+ """Limpa o ruído da voz do corte (cut.mp4) e põe a voz limpa no lugar, sem mexer na imagem nem no tempo.
2
+
3
+ Escrito pelo PrimoCode em cima do EDIT IA PRO (SHK Soluções Digitais, com autorização por escrito, 19/10/2026).
4
+ No caminho do HyperFrames a voz vem do cut.mp4, e a limpeza do denoise.py só rodava dentro do compose_final (motor
5
+ antigo). Aqui ela roda depois do render_cut: a voz é refeita das fontes, trecho a trecho (limpeza antes do ganho, do
6
+ limitador e dos fades, igual ao render_cut), normalizada em -14 LUFS e trocada no cut.mp4. O original fica em
7
+ <edit>/cut_sem_limpeza.mp4 e o relatório em <edit>/verify/voz_limpa.json (com o modo do loudnorm).
8
+
9
+ Uso:
10
+ python helpers/voz_limpa.py <edit> [--modo leve|forte]
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ import denoise # noqa: E402
23
+ from common import load_json # noqa: E402
24
+
25
+
26
+ def main() -> int:
27
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
28
+ ap.add_argument("edit", type=Path)
29
+ ap.add_argument("--modo", default="leve", choices=["leve", "forte"])
30
+ a = ap.parse_args()
31
+ edit = a.edit.resolve()
32
+ cut, tl_path = edit / "cut.mp4", edit / "cut_timeline.json"
33
+ if not cut.exists() or not tl_path.exists():
34
+ print("erro: falta o cut.mp4 ou o cut_timeline.json; rode o render_cut antes", file=sys.stderr)
35
+ return 2
36
+ tl = load_json(tl_path)
37
+ fps = int(round(tl.get("fps") or 30))
38
+ frames = int(tl["frames"])
39
+ total = frames * 48000 // fps
40
+ original = edit / "cut_sem_limpeza.mp4"
41
+ marca = edit / "verify" / "voz_limpa.json"
42
+ assinatura = lambda p: [p.stat().st_size, int(p.stat().st_mtime)] # noqa: E731
43
+ # rodar de novo parte do corte SEM limpeza (senão limparia duas vezes): o cut.mp4 que esta ferramenta gravou
44
+ # tem a assinatura anotada; um render_cut novo troca a assinatura e vira o novo original
45
+ ja_limpo = False
46
+ try:
47
+ ja_limpo = original.exists() and load_json(marca).get("cut_assinatura") == assinatura(cut)
48
+ except Exception: # noqa: BLE001
49
+ ja_limpo = False
50
+ if not ja_limpo:
51
+ shutil.copy2(cut, original)
52
+ base = original
53
+ work = edit / "clips" / "voz_limpa"
54
+ work.mkdir(parents=True, exist_ok=True)
55
+ res = denoise.build_voice(edit, base, tl, fps, total, a.modo, work, keep_temp=False)
56
+ rel = {"modo": a.modo, "voz": str(res.path) if res.path else None, "relatorio": res.report}
57
+ if not res.path or res.report.get("applied") in (None, "off"):
58
+ rel["aviso"] = "a limpeza não se aplicou (sem ruído medível ou sem fonte); o corte ficou como estava"
59
+ (edit / "verify").mkdir(exist_ok=True)
60
+ (edit / "verify" / "voz_limpa.json").write_text(json.dumps(rel, ensure_ascii=False, indent=1, default=str))
61
+ print(rel["aviso"])
62
+ return 0
63
+ dur = float(tl["duration"])
64
+ tmp = edit / ".cut_limpo.part.mp4"
65
+ r = subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(base), "-i", str(res.path), "-map", "0:v:0", "-map",
66
+ "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-t",
67
+ f"{dur:.6f}", "-movflags", "+faststart", str(tmp)], capture_output=True, text=True)
68
+ if r.returncode:
69
+ print("erro: o ffmpeg falhou ao trocar a voz:\n" + r.stderr[-800:], file=sys.stderr)
70
+ return 1
71
+ tmp.replace(cut)
72
+ rel["cut_assinatura"] = assinatura(cut)
73
+ (edit / "verify").mkdir(exist_ok=True)
74
+ marca.write_text(json.dumps(rel, ensure_ascii=False, indent=1, default=str))
75
+ modo_ln = res.report.get("loudnorm_modo")
76
+ print(f"voz limpa ({a.modo}) no cut.mp4; loudnorm {modo_ln}; original em {original.name}")
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main())
@@ -0,0 +1,372 @@
1
+ """Print what the user did in the preview, one flushed line per new event (SPEC Contract 9).
2
+
3
+ Reads edit/preview_marks.json, edit/preview_trims.json, edit/preview_approval.json and
4
+ edit/preview_style.json.
5
+ Remembers what it already reported (by `saved_at`) in edit/preview_cache/watch_seen.json,
6
+ so running it again never repeats an event. Every line ends with the time the person saved it
7
+ ("· salvo às 14:30:14"), so a resumed session can compare it with the last render.
8
+
9
+ When an approval arrives while marks for the same video were saved after its last render, an extra
10
+ `[preview] atenção: ...` line follows. The loop mode writes preview_cache/watcher.json every few
11
+ seconds, so the preview page knows a live watcher is listening (Codex has none).
12
+
13
+ Output is UTF-8 and never stops on a character the console cannot show.
14
+
15
+ Usage:
16
+ uv run python helpers/watch_preview.py --edit-dir DIR [--once] [--interval 1.0]
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ import time
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+
31
+ from common import fmt_time, load_json, load_style_catalog, save_json # noqa: E402
32
+
33
+ SOURCES = (
34
+ ("approval", "preview_approval.json"),
35
+ ("marks", "preview_marks.json"),
36
+ ("trims", "preview_trims.json"),
37
+ ("style", "preview_style.json"),
38
+ )
39
+ PHASE_NAME = {"cut": "corte", "final": "vídeo final"}
40
+ PHASE_IN = {"cut": "no corte", "final": "no vídeo final"}
41
+ SEEN_LIMIT = 50
42
+ HEARTBEAT_S = 3.0
43
+ DEFAULT_MEDIA = {"cut": "cut.mp4", "final": "final.mp4"}
44
+ MEDIA_KEY = {"cut": "cut_video", "final": "final_video"}
45
+ DENOISE_LABEL = {"off": "desligado", "leve": "leve", "forte": "forte"} # preview_style.json "denoise"
46
+ # preview_style.json "music_mood": "off" (Sem trilha), "arquivo" (+ music_file) or a library mood id
47
+ # preview_style.json "tela_verde_posicao" and "tela_verde_inserts_fundo" (with "edit_type": "tela_verde")
48
+ TV_POSITION_LABEL = {"esquerda": "canto esquerdo", "direita": "canto direito", "centro": "meio"}
49
+
50
+
51
+ def utf8_console() -> None:
52
+ """Windows pipes (Monitor, Codex) default to the ANSI code page: an emoji in a note must not crash."""
53
+ for stream in (sys.stdout, sys.stderr):
54
+ try:
55
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
56
+ except (AttributeError, ValueError, OSError):
57
+ pass
58
+
59
+
60
+ def saved_clock(saved_at: str) -> str:
61
+ try:
62
+ return datetime.fromisoformat(str(saved_at)).strftime("%H:%M:%S")
63
+ except ValueError:
64
+ return str(saved_at)
65
+
66
+
67
+ def quote(text: str) -> str:
68
+ """One-line, double-quoted note."""
69
+ clean = " ".join(str(text or "").split())
70
+ return '"' + clean.replace('"', "'") + '"'
71
+
72
+
73
+ def format_approval(rec: dict) -> str:
74
+ phase = PHASE_NAME.get(rec.get("phase"), str(rec.get("phase")))
75
+ line = f"[preview] {phase} APROVADO"
76
+ note = (rec.get("note") or "").strip()
77
+ if note:
78
+ line += f" · nota: {quote(note)}"
79
+ return line
80
+
81
+
82
+ def format_marks(rec: dict) -> str:
83
+ marks = rec.get("marks") or []
84
+ where = PHASE_IN.get(rec.get("phase"), f"em {rec.get('phase')}")
85
+ if not marks:
86
+ return f"[preview] marcações {where} apagadas (0 marcações)"
87
+ parts = []
88
+ for m in marks:
89
+ note = (m.get("note") or "").strip()
90
+ if m.get("kind") == "motion":
91
+ piece = f"MOTION AQUI em {fmt_time(float(m.get('in', 0)))}"
92
+ parts.append(f"{piece} {quote(note)}" if note else f"{piece} (o agente escolhe o modelo pela fala)")
93
+ continue
94
+ piece = f"{fmt_time(float(m.get('in', 0)))} a {fmt_time(float(m.get('out', 0)))}"
95
+ parts.append(f"{piece} {quote(note)}" if note else f"{piece} (sem nota)")
96
+ label = "marcação" if len(marks) == 1 else "marcações"
97
+ return f"[preview] {len(marks)} {label} {where}: " + "; ".join(parts)
98
+
99
+
100
+ def format_trims(rec: dict) -> str:
101
+ """Pedidos de esticar/encolher a borda de um trecho, em segundos do EDL."""
102
+ trims = rec.get("trims") or []
103
+ where = PHASE_IN.get(rec.get("phase"), f"em {rec.get('phase')}")
104
+ if not trims:
105
+ return f"[preview] ajustes de trecho {where} apagados (0 ajustes)"
106
+ parts = []
107
+ for tr in trims:
108
+ rid = str(tr.get("id", "?"))
109
+ bits = []
110
+ for key, onde in (("start_delta", "no começo"), ("end_delta", "no fim")):
111
+ delta = float(tr.get(key) or 0.0)
112
+ if not delta:
113
+ continue
114
+ verbo = "estica" if (delta < 0 if key == "start_delta" else delta > 0) else "encolhe"
115
+ bits.append(f"{verbo} {abs(delta):.2f}s {onde}")
116
+ quote_txt = (tr.get("quote") or "").strip()
117
+ peca = f"{rid}: " + " e ".join(bits or ["sem mudança"])
118
+ parts.append(f"{peca} {quote(quote_txt)}" if quote_txt else peca)
119
+ label = "ajuste de trecho" if len(trims) == 1 else "ajustes de trecho"
120
+ return (f"[preview] {len(trims)} {label} {where}: " + "; ".join(parts)
121
+ + " · aplique no edl.json (start_delta soma no start, end_delta soma no end) e renderize de novo")
122
+
123
+
124
+ def format_style(rec: dict, catalog: dict) -> str:
125
+ elements = rec.get("elements") or {}
126
+ order = [e.get("id") for e in (catalog.get("elements") or []) if e.get("id")]
127
+ order += [k for k in elements if k not in order]
128
+ on = [k for k in order if elements.get(k) is True]
129
+ off = [k for k in order if k in elements and elements.get(k) is False]
130
+ edit_type = rec.get("edit_type") or "-"
131
+ if edit_type == "tela_verde":
132
+ pos = rec.get("tela_verde_posicao") or "direita"
133
+ moved = rec.get("tela_verde_inserts_fundo")
134
+ if isinstance(moved, int) and not isinstance(moved, bool) and moved > 0:
135
+ what = ("o insert de imagem em cima ou embaixo vai" if moved == 1 else
136
+ f"os {moved} inserts de imagem em cima ou embaixo vão")
137
+ what += " para o fundo, sem tela dividida"
138
+ else:
139
+ what = "imagens e vídeos dos inserts vão para o fundo, sem tela dividida"
140
+ edit_type += f" ({TV_POSITION_LABEL.get(pos, pos)}: tela_verde.posicao \"{pos}\"; {what})"
141
+ fields = [
142
+ f"[preview] estilo salvo: {edit_type}",
143
+ f"legenda {rec.get('caption_style') or '-'}",
144
+ f"gancho {rec.get('hook_style') or '-'}",
145
+ f"cor {rec.get('accent') or '-'}",
146
+ "elementos: " + (", ".join(on) if on else "nenhum"),
147
+ "fora: " + (", ".join(off) if off else "nenhum"),
148
+ ]
149
+ if isinstance(elements.get("motion"), bool):
150
+ if elements["motion"] and rec.get("motion_qtd"):
151
+ n = {"poucas": 3, "algumas": 5}.get(rec["motion_qtd"], 3)
152
+ tema = rec.get("motion_tema") or "papelaria"
153
+ fields.append(f"animações: ligadas, {rec['motion_qtd']} (motion.quantidade {n}), visual {tema} "
154
+ f"(motion.tema \"{tema}\")")
155
+ else:
156
+ fields.append(f"animações: {'ligadas' if elements['motion'] else 'desligadas'}")
157
+ if isinstance(rec.get("denoise"), str) and rec["denoise"] in DENOISE_LABEL:
158
+ fields.append(f"ruído: {DENOISE_LABEL[rec['denoise']]}")
159
+ mood = rec.get("music_mood")
160
+ if isinstance(mood, str) and mood:
161
+ if mood == "off":
162
+ fields.append("trilha: sem")
163
+ elif mood == "arquivo":
164
+ fields.append(f"trilha: arquivo da pasta ({rec.get('music_file') or '?'})")
165
+ else:
166
+ fields.append(f"trilha: {mood}")
167
+ if (rec.get("hook_text") or "").strip():
168
+ fields.append(f"texto do gancho: {quote(rec['hook_text'])}")
169
+ if (rec.get("cta_text") or "").strip():
170
+ fields.append(f"chamada: {quote(rec['cta_text'])}")
171
+ if (rec.get("note") or "").strip():
172
+ fields.append(f"nota: {quote(rec['note'])}")
173
+ return " · ".join(fields)
174
+
175
+
176
+ def read_record(path: Path) -> dict | None:
177
+ for attempt in range(3):
178
+ try:
179
+ data = load_json(path)
180
+ return data if isinstance(data, dict) else None
181
+ except json.JSONDecodeError:
182
+ time.sleep(0.05)
183
+ except OSError:
184
+ return None
185
+ return None
186
+
187
+
188
+ def load_seen(path: Path) -> dict[str, list[str]]:
189
+ try:
190
+ data = load_json(path, default={}) or {}
191
+ except (json.JSONDecodeError, OSError):
192
+ data = {}
193
+ seen: dict[str, list[str]] = {}
194
+ for kind, _ in SOURCES:
195
+ value = data.get(kind)
196
+ if isinstance(value, str):
197
+ value = [value]
198
+ seen[kind] = [v for v in (value or []) if isinstance(v, str)]
199
+ return seen
200
+
201
+
202
+ def collect_new(edit_dir: Path, seen: dict[str, list[str]]) -> list[tuple[str, str, dict]]:
203
+ events = []
204
+ for kind, filename in SOURCES:
205
+ path = edit_dir / filename
206
+ if not path.is_file():
207
+ continue
208
+ rec = read_record(path)
209
+ if not rec:
210
+ continue
211
+ saved_at = rec.get("saved_at")
212
+ if not isinstance(saved_at, str) or not saved_at:
213
+ continue
214
+ if saved_at in seen.get(kind, []):
215
+ continue
216
+ events.append((saved_at, kind, rec))
217
+ events.sort(key=lambda e: e[0])
218
+ return events
219
+
220
+
221
+ def media_mtime(edit_dir: Path, phase: str) -> float | None:
222
+ """mtime of the video the person was reviewing for this phase."""
223
+ rel = DEFAULT_MEDIA.get(phase)
224
+ try:
225
+ state = load_json(edit_dir / "state.json") or {}
226
+ if isinstance(state, dict) and isinstance(state.get(MEDIA_KEY.get(phase, "")), str):
227
+ rel = state[MEDIA_KEY[phase]]
228
+ except (json.JSONDecodeError, OSError):
229
+ pass
230
+ try:
231
+ return (edit_dir / rel).stat().st_mtime if rel else None
232
+ except OSError:
233
+ return None
234
+
235
+
236
+ def iso_epoch(saved_at: str) -> float | None:
237
+ try:
238
+ return datetime.fromisoformat(str(saved_at)).timestamp()
239
+ except ValueError:
240
+ return None
241
+
242
+
243
+ def pending_marks_note(edit_dir: Path, approval: dict) -> str | None:
244
+ """Marks for the same video saved after its render and before the approval: the agent must ask."""
245
+ phase = approval.get("phase")
246
+ marks = read_record(edit_dir / "preview_marks.json")
247
+ if not marks or marks.get("phase") != phase or not marks.get("marks"):
248
+ return None
249
+ marks_t, approval_t = iso_epoch(marks.get("saved_at", "")), iso_epoch(approval.get("saved_at", ""))
250
+ render_t = media_mtime(edit_dir, str(phase))
251
+ if marks_t is None or approval_t is None or marks_t > approval_t + 0.001:
252
+ return None
253
+ if render_t is not None and marks_t < render_t:
254
+ return None # those marks were for an older version, already handled
255
+ n = len(marks["marks"])
256
+ where = PHASE_IN.get(phase, f"em {phase}")
257
+ label = "marcação" if n == 1 else "marcações"
258
+ return (f"[preview] atenção: a aprovação veio junto com {n} {label} {where} salvas depois do último render. "
259
+ "Pergunte se é para ajustar antes de seguir.")
260
+
261
+
262
+ def stale_approval_note(edit_dir: Path, approval: dict) -> str | None:
263
+ """The approval names the file version the person watched; a newer render needs a new approval."""
264
+ rel, mtime, size = approval.get("file"), approval.get("file_mtime"), approval.get("file_size")
265
+ if not isinstance(rel, str) or mtime is None:
266
+ return None
267
+ try:
268
+ st = (edit_dir / rel).stat()
269
+ except OSError:
270
+ return None
271
+ if abs(st.st_mtime - float(mtime)) <= 0.01 and (size is None or int(size) == st.st_size):
272
+ return None
273
+ name = PHASE_NAME.get(approval.get("phase"), str(approval.get("phase")))
274
+ return (f"[preview] atenção: essa aprovação é de uma versão anterior do {name} ({rel} mudou depois). "
275
+ "Peça para a pessoa assistir e aprovar de novo.")
276
+
277
+
278
+ def emit(line: str) -> None:
279
+ try:
280
+ print(line, flush=True)
281
+ except (UnicodeError, OSError):
282
+ try:
283
+ sys.stdout.buffer.write((line + "\n").encode("utf-8", "replace"))
284
+ sys.stdout.flush()
285
+ except (AttributeError, OSError):
286
+ pass
287
+
288
+
289
+ def report(edit_dir: Path, seen_path: Path, seen: dict[str, list[str]], catalog: dict) -> int:
290
+ events = collect_new(edit_dir, seen)
291
+ if not events:
292
+ return 0
293
+ for saved_at, kind, rec in events:
294
+ # mark it seen first: one line that fails to print must never block every later event
295
+ seen[kind] = (seen.get(kind, []) + [saved_at])[-SEEN_LIMIT:]
296
+ try:
297
+ if kind == "approval":
298
+ line = format_approval(rec)
299
+ elif kind == "marks":
300
+ line = format_marks(rec)
301
+ elif kind == "trims":
302
+ line = format_trims(rec)
303
+ else:
304
+ line = format_style(rec, catalog)
305
+ except (TypeError, ValueError, AttributeError) as exc:
306
+ line = f"[preview] {kind}: não consegui ler o registro ({exc}); abra o arquivo preview_{kind}.json"
307
+ emit(f"{line} · salvo às {saved_clock(saved_at)}")
308
+ if kind == "approval":
309
+ for note in (stale_approval_note(edit_dir, rec), pending_marks_note(edit_dir, rec)):
310
+ if note:
311
+ emit(note)
312
+ try:
313
+ save_json(seen_path, seen)
314
+ except OSError as exc:
315
+ print(f"Aviso: não consegui gravar {seen_path}: {exc}", file=sys.stderr, flush=True)
316
+ return len(events)
317
+
318
+
319
+ def heartbeat(edit_dir: Path) -> None:
320
+ try:
321
+ save_json(edit_dir / "preview_cache" / "watcher.json", {"mode": "loop", "pid": os.getpid(), "updated_at": time.time()})
322
+ except OSError:
323
+ pass
324
+
325
+
326
+ def main() -> int:
327
+ parser = argparse.ArgumentParser(
328
+ description="Mostra, uma linha por evento, o que a pessoa fez no preview (aprovação, marcações, estilo).",
329
+ )
330
+ parser.add_argument("--edit-dir", required=True, help="pasta edit/ da sessão")
331
+ parser.add_argument("--once", action="store_true", help="verifica uma vez e sai")
332
+ parser.add_argument("--interval", type=float, default=1.0, help="segundos entre verificações (padrão 1.0)")
333
+ args = parser.parse_args()
334
+ utf8_console()
335
+
336
+ edit_dir = Path(args.edit_dir).expanduser().resolve()
337
+ if not edit_dir.is_dir():
338
+ print(f"Erro: a pasta de edição não existe: {edit_dir}", file=sys.stderr)
339
+ return 2
340
+ if args.interval <= 0:
341
+ print("Erro: --interval precisa ser maior que zero.", file=sys.stderr)
342
+ return 2
343
+ seen_path = edit_dir / "preview_cache" / "watch_seen.json"
344
+ seen = load_seen(seen_path)
345
+ catalog = load_style_catalog()
346
+
347
+ if args.once:
348
+ if report(edit_dir, seen_path, seen, catalog) == 0:
349
+ print("[preview] nada novo", file=sys.stderr, flush=True)
350
+ return 0
351
+
352
+ print("Esperando ações no preview. Ctrl+C para parar.", file=sys.stderr, flush=True)
353
+ last_beat = 0.0
354
+ try:
355
+ while True:
356
+ if not catalog:
357
+ catalog = load_style_catalog()
358
+ if time.time() - last_beat >= HEARTBEAT_S:
359
+ heartbeat(edit_dir)
360
+ last_beat = time.time()
361
+ report(edit_dir, seen_path, seen, catalog)
362
+ time.sleep(args.interval)
363
+ except KeyboardInterrupt:
364
+ try:
365
+ (edit_dir / "preview_cache" / "watcher.json").unlink()
366
+ except OSError:
367
+ pass
368
+ return 0
369
+
370
+
371
+ if __name__ == "__main__":
372
+ sys.exit(main())