primocode 10.15.2 → 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 (245) 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 +74 -9
  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 +38 -8
  198. package/lib/app-chat.js +97 -0
  199. package/lib/catalogo.js +7 -1
  200. package/lib/chamada-torta.js +81 -0
  201. package/lib/editor-pro.js +786 -0
  202. package/lib/entrega.js +58 -0
  203. package/lib/estudio/conferencia-visual.js +76 -0
  204. package/lib/estudio/diretor.js +6 -1
  205. package/lib/estudio/exportar.js +3 -1
  206. package/lib/estudio/gosto/tela.html +1349 -0
  207. package/lib/estudio/gosto.js +305 -0
  208. package/lib/estudio/index.js +12 -1
  209. package/lib/estudio/kit-ae/base.jsx +119 -13
  210. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  211. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  212. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  213. package/lib/estudio/maquina-diretor.js +70 -12
  214. package/lib/estudio/maquina-foto.js +177 -0
  215. package/lib/estudio/maquina-guarda.js +207 -0
  216. package/lib/estudio/maquina-layout.js +22 -1
  217. package/lib/estudio/maquina.js +64 -11
  218. package/lib/estudio/referencias.js +1 -1
  219. package/lib/estudio/visao/visao.swift +97 -0
  220. package/lib/estudio/visao.js +60 -0
  221. package/lib/headless.js +15 -3
  222. package/lib/local/cerebro.json +285 -7
  223. package/lib/local/motor.js +5 -2
  224. package/lib/memoria-usuario.js +164 -0
  225. package/lib/nuvem.js +33 -2
  226. package/lib/passagem.js +50 -0
  227. package/lib/perigo.js +19 -11
  228. package/lib/protecao.js +134 -0
  229. package/lib/repeticao.js +1 -1
  230. package/lib/sandbox.js +57 -9
  231. package/lib/titulo-conversa.js +46 -0
  232. package/lib/tools.js +23 -4
  233. package/lib/ui.js +2 -2
  234. package/package.json +3 -2
  235. package/skills/editar-meu-video/SKILL.md +6 -2
  236. package/skills/editar-video/SKILL.md +113 -0
  237. package/studio/pessoa.py +52 -0
  238. package/voz/primo_voz/__main__.py +55 -40
  239. package/voz/primo_voz/app.py +500 -4
  240. package/voz/primo_voz/chat.html +1773 -0
  241. package/voz/primo_voz/conversa.py +0 -26
  242. package/voz/primo_voz/hud.html +8 -2
  243. package/voz/primo_voz/hud.py +129 -5
  244. package/voz/primo_voz/identidade.py +1 -1
  245. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,786 @@
1
+ /**
2
+ * editor-pro.js — o motor de edição de VÍDEO FALADO do PrimoCode.
3
+ *
4
+ * "ficou muito bom a edição do EDIT IA PRO; pode usar só ele pra edição,
5
+ * toda a arquitetura dele, os arquivos e tudo" (Joel, 20/10/2026)
6
+ *
7
+ * O motor é o EDIT IA PRO (SHK Soluções Digitais), usado com autorização por
8
+ * escrito do autor (19/10/2026), com o crédito em LICENSES/edit-ia-pro.txt.
9
+ * Ele mora em `editor/` com a arquitetura dele: os helpers Python medem e
10
+ * fazem o trabalho pesado (transcrição, corte pela onda, voz, rosto, sons,
11
+ * portão), o projeto HyperFrames (`editor/modelo-projeto`) monta a edição
12
+ * (legenda animada, câmera viva, peças, b-roll) e renderiza.
13
+ *
14
+ * ── ONDE AS COISAS FICAM ────────────────────────────────────────────────
15
+ * editor/ no pacote: só texto (helpers, modelo, guias)
16
+ * ~/.primocode/editor/<fp>/ a cópia que roda, com os arquivos grandes
17
+ * (modelos, fontes, sons) baixados na primeira
18
+ * edição e conferidos pelo sha256
19
+ * ~/.primocode/editor/cache/ cada arquivo baixado, pelo sha256 (uma
20
+ * atualização do PrimoCode não baixa de novo)
21
+ * ~/.primocode/editor-venv/ o Python do editor: venv próprio, criado sob
22
+ * demanda (uv se existir, senão venv + pip).
23
+ * Nada no Python do sistema.
24
+ *
25
+ * ── O CAMINHO ───────────────────────────────────────────────────────────
26
+ * cortar transcreve, monta o corte (a última tomada inteira de cada
27
+ * frase), mede e aplica o GANHO POR TRECHO (a voz baixa chega no
28
+ * alvo antes do loudnorm, que fica linear), corta os respiros pela
29
+ * onda, renderiza, limpa a voz e confere. Devolve a fala do corte
30
+ * com os tempos: é com ela que o modelo escreve o plano.
31
+ * montar cria o projeto HyperFrames, aplica o plano (gancho, peças,
32
+ * b-roll, blocos), câmera viva, sons casados com cada animação,
33
+ * renderiza, mixa a trilha com ducking e roda o PORTÃO. Só entrega
34
+ * com PASSOU.
35
+ * editar os dois de uma vez. Sem plano, o plano automático entra (gancho
36
+ * da primeira frase + as peças que a fala pede).
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ const fs = require('fs');
42
+ const os = require('os');
43
+ const path = require('path');
44
+ const crypto = require('crypto');
45
+ const { spawn, spawnSync } = require('child_process');
46
+
47
+ const ORIGEM = path.join(__dirname, '..', 'editor');
48
+ const casa = () => process.env.PRIMOCODE_EDITOR_CASA || path.join(os.homedir(), '.primocode', 'editor');
49
+ const venvDir = () => process.env.PRIMOCODE_EDITOR_VENV || path.join(os.homedir(), '.primocode', 'editor-venv');
50
+ const WIN = process.platform === 'win32';
51
+ const venvPython = (v = venvDir()) => path.join(v, WIN ? 'Scripts' : 'bin', WIN ? 'python.exe' : 'python');
52
+ const MANIFESTO = () => JSON.parse(fs.readFileSync(path.join(ORIGEM, 'assets', 'baixar.json'), 'utf8'));
53
+
54
+ const VIDEO_EXT = /\.(mov|mp4|m4v|mkv|webm)$/i;
55
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
56
+ const sha256Arquivo = (p) => sha256(fs.readFileSync(p));
57
+ const r3 = (x) => Math.round(x * 1000) / 1000;
58
+ const falar = (ctx) => (ctx && typeof ctx.progresso === 'function') ? ctx.progresso : ((m) => { try { process.stderr.write(` · ${m}\n`); } catch { /* sem terminal */ } });
59
+
60
+ // ── 1. os arquivos do editor ─────────────────────────────────────────────
61
+
62
+ function listar(dir, base = dir, out = []) {
63
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
64
+ if (e.name === '__pycache__' || e.name === '.DS_Store') continue;
65
+ const p = path.join(dir, e.name);
66
+ if (e.isDirectory()) listar(p, base, out);
67
+ else out.push(path.relative(base, p));
68
+ }
69
+ return out.sort();
70
+ }
71
+
72
+ /** A impressão digital da pasta `editor/` do pacote: muda quando um helper muda, e a cópia que roda é refeita. */
73
+ function impressao(origem = ORIGEM) {
74
+ const h = crypto.createHash('sha1');
75
+ for (const rel of listar(origem)) h.update(rel + ':' + fs.statSync(path.join(origem, rel)).size + '\n');
76
+ return h.digest('hex').slice(0, 12);
77
+ }
78
+
79
+ async function baixarUrl(url) {
80
+ if (/^(file:\/\/|\/)/.test(url) || /^[A-Za-z]:\\/.test(url)) return fs.readFileSync(url.replace(/^file:\/\//, ''));
81
+ const r = await fetch(url, { redirect: 'follow' });
82
+ if (!r.ok) throw new Error(`HTTP ${r.status} em ${url}`);
83
+ return Buffer.from(await r.arrayBuffer());
84
+ }
85
+
86
+ /** De onde vem um arquivo do grupo `espelho`: PRIMOCODE_EDITOR_ESPELHO (pasta local ou URL) ou o espelho do manifesto. */
87
+ function urlDoEspelho(item, manifesto) {
88
+ const base = process.env.PRIMOCODE_EDITOR_ESPELHO || manifesto.espelho;
89
+ if (!base) return null;
90
+ if (/^https?:\/\//.test(base)) return base.replace(/\/?$/, '/') + item.caminho;
91
+ return path.join(base, item.caminho);
92
+ }
93
+
94
+ /**
95
+ * Um arquivo do manifesto no lugar, conferido. Cache por sha256 em ~/.primocode/editor/cache.
96
+ * deps.baixar(url) → Buffer (testes).
97
+ */
98
+ async function garantirArquivo(destino, item, manifesto, { deps = {} } = {}) {
99
+ const alvo = path.join(destino, item.caminho);
100
+ if (fs.existsSync(alvo) && fs.statSync(alvo).size === item.bytes && sha256Arquivo(alvo) === item.sha256) return { ok: true, ja: true };
101
+ const cache = path.join(casa(), 'cache', item.sha256);
102
+ let buf = null;
103
+ if (fs.existsSync(cache)) {
104
+ const b = fs.readFileSync(cache);
105
+ if (sha256(b) === item.sha256) buf = b;
106
+ }
107
+ if (!buf) {
108
+ const url = item.url || urlDoEspelho(item, manifesto);
109
+ if (!url) return { ok: false, error: `sem origem para ${item.caminho}` };
110
+ const b = await (deps.baixar || baixarUrl)(url);
111
+ const got = sha256(b);
112
+ if (got !== item.sha256) return { ok: false, error: `sha256 de ${item.caminho} não confere (veio ${got.slice(0, 12)}…, esperado ${item.sha256.slice(0, 12)}…)` };
113
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
114
+ fs.writeFileSync(cache, b);
115
+ buf = b;
116
+ }
117
+ fs.mkdirSync(path.dirname(alvo), { recursive: true });
118
+ fs.writeFileSync(alvo, buf);
119
+ return { ok: true };
120
+ }
121
+
122
+ /**
123
+ * A cópia do editor que roda, com os arquivos grandes. Idempotente: na segunda vez só confere a marca.
124
+ * grupos: 'essencial' sempre; 'espelho' quando pedido (tela verde, kit de sons), sem falhar a edição.
125
+ */
126
+ async function instalarArquivos({ ctx, deps = {}, espelho = false } = {}) {
127
+ const dizer = falar(ctx);
128
+ const fp = impressao();
129
+ const destino = path.join(casa(), fp);
130
+ const marca = path.join(destino, '.primocode-pronto.json');
131
+ const manifesto = MANIFESTO();
132
+ let estado = null;
133
+ try { estado = JSON.parse(fs.readFileSync(marca, 'utf8')); } catch { /* primeira vez */ }
134
+ if (!estado) {
135
+ dizer('preparando o editor de vídeo (só na primeira vez): copiando os arquivos…');
136
+ fs.mkdirSync(destino, { recursive: true });
137
+ fs.cpSync(ORIGEM, destino, { recursive: true, filter: (s) => !/__pycache__|\.DS_Store$/.test(s) });
138
+ const essenciais = manifesto.arquivos.filter((a) => a.grupo === 'essencial' && !a.gerado);
139
+ const mb = (essenciais.reduce((s, a) => s + a.bytes, 0) / 1048576).toFixed(0);
140
+ dizer(`baixando os sons, as fontes e o modelo de rosto (${mb} MB, conferidos um a um)…`);
141
+ const falhas = [];
142
+ let feitos = 0;
143
+ await Promise.all(Array.from({ length: 6 }, async () => {
144
+ while (essenciais.length) {
145
+ const it = essenciais.shift();
146
+ try {
147
+ const r = await garantirArquivo(destino, it, manifesto, { deps });
148
+ if (!r.ok) falhas.push(r.error);
149
+ } catch (e) { falhas.push(`${it.caminho}: ${e.message}`); }
150
+ feitos++;
151
+ }
152
+ }));
153
+ if (falhas.length) return { ok: false, error: `não consegui baixar ${falhas.length} arquivo(s) do editor: ${falhas.slice(0, 3).join('; ')}`, destino };
154
+ estado = { fp, em: new Date().toISOString(), arquivos: feitos, espelho: false, gerados: false };
155
+ fs.writeFileSync(marca, JSON.stringify(estado, null, 1));
156
+ // a cópia de uma versão anterior do editor sai (os arquivos grandes ficam no cache)
157
+ for (const d of fs.readdirSync(casa())) {
158
+ if (d !== fp && d !== 'cache' && /^[0-9a-f]{12}$/.test(d)) { try { fs.rmSync(path.join(casa(), d), { recursive: true, force: true }); } catch { /* em uso */ } }
159
+ }
160
+ }
161
+ const avisos = [];
162
+ if (espelho && !estado.espelho) {
163
+ const extras = manifesto.arquivos.filter((a) => a.grupo === 'espelho');
164
+ let ok = 0;
165
+ for (const it of extras) {
166
+ try { const r = await garantirArquivo(destino, it, manifesto, { deps }); if (r.ok) ok++; else avisos.push(r.error); }
167
+ catch (e) { avisos.push(`${it.caminho}: ${e.message}`); }
168
+ }
169
+ if (ok === extras.length) { estado.espelho = true; fs.writeFileSync(marca, JSON.stringify(estado, null, 1)); }
170
+ else avisos.unshift(`${extras.length - ok} arquivo(s) extra(s) do editor não vieram (tela verde e o kit de sons reserva ficam de fora)`);
171
+ }
172
+ return { ok: true, destino, avisos };
173
+ }
174
+
175
+ /** Os sons que o próprio editor gera (make_sfx, semente fixa): refeitos no lugar e conferidos pelo sha256. */
176
+ function gerarSintetizados(destino, py, { ctx } = {}) {
177
+ const marca = path.join(destino, '.primocode-pronto.json');
178
+ let estado = {};
179
+ try { estado = JSON.parse(fs.readFileSync(marca, 'utf8')); } catch { /* sem marca */ }
180
+ if (estado.gerados) return { ok: true, ja: true };
181
+ const itens = MANIFESTO().arquivos.filter((a) => a.gerado === 'make_sfx');
182
+ if (!itens.length) return { ok: true };
183
+ falar(ctx)('gerando os efeitos sintéticos do editor…');
184
+ const dir = path.join(destino, 'assets', 'sfx', 'sinteticos');
185
+ const r = spawnSync(py, [path.join(destino, 'helpers', 'make_sfx.py'), '--out-dir', dir], { cwd: destino, encoding: 'utf8', timeout: 300000, env: ambiente(destino, py) });
186
+ if (r.status !== 0) return { ok: false, error: 'o make_sfx falhou: ' + String(r.stderr || r.stdout).slice(-300) };
187
+ const errados = itens.filter((it) => { const p = path.join(destino, it.caminho); return !fs.existsSync(p) || sha256Arquivo(p) !== it.sha256; }).map((it) => it.caminho);
188
+ estado.gerados = true;
189
+ if (errados.length) estado.geradosDiferentes = errados;
190
+ fs.writeFileSync(marca, JSON.stringify(estado, null, 1));
191
+ return { ok: true, diferentes: errados };
192
+ }
193
+
194
+ // ── 2. o Python do editor ────────────────────────────────────────────────
195
+
196
+ function acharUv() {
197
+ if (process.env.PRIMOCODE_EDITOR_UV) return process.env.PRIMOCODE_EDITOR_UV === 'nenhum' ? null : process.env.PRIMOCODE_EDITOR_UV;
198
+ const cands = [path.join(os.homedir(), '.local', 'bin', WIN ? 'uv.exe' : 'uv'), path.join(os.homedir(), '.cargo', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv'];
199
+ try { const r = spawnSync(WIN ? 'where' : 'which', ['uv'], { encoding: 'utf8', timeout: 5000 }); if (r.status === 0) cands.unshift(r.stdout.split('\n')[0].trim()); } catch { /* sem which */ }
200
+ return cands.find((c) => c && fs.existsSync(c)) || null;
201
+ }
202
+
203
+ /** Um Python 3.10 a 3.13 do sistema (o pyproject do editor pede >=3.10,<3.14), só para criar o venv. */
204
+ function acharPython() {
205
+ if (process.env.PRIMOCODE_EDITOR_PY_SISTEMA) return process.env.PRIMOCODE_EDITOR_PY_SISTEMA;
206
+ const nomes = WIN ? ['py', 'python', 'python3'] : ['python3.12', 'python3.11', 'python3.13', 'python3.10', 'python3', '/opt/homebrew/bin/python3.12', '/opt/homebrew/bin/python3.11', '/usr/local/bin/python3.12'];
207
+ for (const n of nomes) {
208
+ try {
209
+ const v = spawnSync(n, ['-c', 'import sys;print(sys.version_info[0],sys.version_info[1])'], { encoding: 'utf8', timeout: 8000 });
210
+ if (v.status !== 0) continue;
211
+ const [a, b] = v.stdout.trim().split(/\s+/).map(Number);
212
+ if (a === 3 && b >= 10 && b <= 13) return n;
213
+ } catch { /* próximo */ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /** As dependências do pyproject.toml do editor (com os marcadores de sistema: o pip os entende). */
219
+ function dependencias(pyproject) {
220
+ const txt = fs.readFileSync(pyproject, 'utf8');
221
+ const m = txt.match(/dependencies\s*=\s*\[([\s\S]*?)\n\]/);
222
+ if (!m) return [];
223
+ return [...m[1].matchAll(/^\s*"([^"]+)"\s*,?\s*$/gm)].map((x) => x[1]);
224
+ }
225
+
226
+ function correr(cmd, args, opts = {}) {
227
+ return new Promise((resolve) => {
228
+ let saida = '';
229
+ let p;
230
+ try { p = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); }
231
+ catch (e) { resolve({ code: -1, saida: e.message }); return; }
232
+ const pega = (d) => { saida += d.toString(); if (saida.length > 400000) saida = saida.slice(-200000); if (opts.aoLer) opts.aoLer(d.toString()); };
233
+ p.stdout.on('data', pega);
234
+ p.stderr.on('data', pega);
235
+ let timer = null;
236
+ if (opts.timeout) timer = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* já foi */ } saida += `\n[parado: passou de ${Math.round(opts.timeout / 1000)} s]`; }, opts.timeout);
237
+ p.on('error', (e) => { if (timer) clearTimeout(timer); resolve({ code: -1, saida: saida + e.message }); });
238
+ p.on('close', (code) => { if (timer) clearTimeout(timer); resolve({ code, saida }); });
239
+ });
240
+ }
241
+
242
+ /**
243
+ * O venv do editor, criado sob demanda. Marca em <venv>/.primocode-editor.json com o sha do pyproject: mudou a
244
+ * lista de bibliotecas, sincroniza de novo. deps: { uv, pythonSistema, correr } (testes).
245
+ */
246
+ async function garantirVenv(destino, { ctx, deps = {} } = {}) {
247
+ const dizer = falar(ctx);
248
+ const v = venvDir();
249
+ const py = venvPython(v);
250
+ const pyproject = path.join(destino, 'pyproject.toml');
251
+ const assinatura = sha256Arquivo(pyproject).slice(0, 16);
252
+ const marca = path.join(v, '.primocode-editor.json');
253
+ try {
254
+ const m = JSON.parse(fs.readFileSync(marca, 'utf8'));
255
+ if (m.pyproject === assinatura && fs.existsSync(py)) return { ok: true, python: py, ja: true };
256
+ } catch { /* sem marca */ }
257
+ const run = deps.correr || correr;
258
+ const uv = deps.uv !== undefined ? deps.uv : acharUv();
259
+ dizer('instalando as bibliotecas do editor num Python só dele (só na primeira vez; uns minutos)…');
260
+ let jeito;
261
+ if (uv) {
262
+ jeito = 'uv';
263
+ const env = { ...process.env, UV_PROJECT_ENVIRONMENT: v };
264
+ const r = await run(uv, ['sync', '--frozen', '--project', destino], { env, cwd: destino, timeout: 1800000 });
265
+ if (r.code !== 0) return { ok: false, error: 'o uv não conseguiu instalar as bibliotecas do editor: ' + String(r.saida).trim().split('\n').slice(-3).join(' ') };
266
+ } else {
267
+ jeito = 'pip';
268
+ const base = deps.pythonSistema !== undefined ? deps.pythonSistema : acharPython();
269
+ if (!base) return { ok: false, error: 'o editor precisa do Python 3.10 a 3.13 (ou do uv). No Mac: brew install uv · no Windows: winget install astral-sh.uv' };
270
+ const r1 = await run(base, ['-m', 'venv', v], { timeout: 300000 });
271
+ if (r1.code !== 0) return { ok: false, error: 'não consegui criar o venv do editor: ' + String(r1.saida).slice(-300) };
272
+ // PEP 668: o pip roda DENTRO do venv do editor, então não precisa (nem deve) usar --break-system-packages
273
+ const r2 = await run(py, ['-m', 'pip', 'install', '--quiet', '--disable-pip-version-check', ...dependencias(pyproject)], { timeout: 1800000 });
274
+ if (r2.code !== 0) return { ok: false, error: 'o pip não conseguiu instalar as bibliotecas do editor: ' + String(r2.saida).trim().split('\n').slice(-3).join(' ') };
275
+ }
276
+ if (!fs.existsSync(py)) return { ok: false, error: `o venv do editor não ficou de pé (${py} não existe)` };
277
+ fs.mkdirSync(v, { recursive: true });
278
+ fs.writeFileSync(marca, JSON.stringify({ pyproject: assinatura, jeito, em: new Date().toISOString() }, null, 1));
279
+ dizer(`Python do editor pronto (${jeito}).`);
280
+ return { ok: true, python: py, jeito };
281
+ }
282
+
283
+ function ambiente(destino, py) {
284
+ const env = { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PRIMOCODE_EDITOR_PYTHON: py, UV_PROJECT_ENVIRONMENT: venvDir() };
285
+ // o npx do HyperFrames chama o node da mesma pasta: o Node que roda o PrimoCode vai na frente
286
+ env.PATH = [path.dirname(process.execPath), '/opt/homebrew/bin', '/usr/local/bin', env.PATH || ''].join(path.delimiter);
287
+ return env;
288
+ }
289
+
290
+ /** Tudo de pé: arquivos, venv, sons gerados, ffmpeg. Devolve { ok, destino, python }. */
291
+ async function garantir({ ctx, deps = {}, espelho = false } = {}) {
292
+ const ff = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', timeout: 8000, env: ambiente('', '') });
293
+ if (ff.status !== 0 && !deps.semFfmpeg) return { ok: false, error: 'o editor precisa do ffmpeg. No Mac: brew install ffmpeg · no Windows: winget install Gyan.FFmpeg' };
294
+ const a = await instalarArquivos({ ctx, deps, espelho });
295
+ if (!a.ok) return a;
296
+ const v = await garantirVenv(a.destino, { ctx, deps });
297
+ if (!v.ok) return v;
298
+ const g = deps.semGerar ? { ok: true } : gerarSintetizados(a.destino, v.python, { ctx });
299
+ return { ok: true, destino: a.destino, python: v.python, avisos: [...(a.avisos || []), ...(g.ok ? [] : [g.error])] };
300
+ }
301
+
302
+ // ── 3. rodar um helper ───────────────────────────────────────────────────
303
+
304
+ /** Roda helpers/<nome>.py com o Python do editor. { ok, code, saida }. */
305
+ async function helper(E, nome, args, { timeout = 1800000, cwd, ctx, mostrar = false } = {}) {
306
+ const arq = path.join(E.destino, 'helpers', nome.endsWith('.py') ? nome : nome + '.py');
307
+ const aoLer = mostrar ? ((t) => t.split('\n').filter((l) => /^(ok|baixando|recortando|transcrevendo|render|\s*\d+%)/i.test(l.trim())).forEach((l) => falar(ctx)(l.trim()))) : undefined;
308
+ const r = await correr(E.python, [arq, ...args.map(String)], { cwd: cwd || E.destino, env: ambiente(E.destino, E.python), timeout, aoLer });
309
+ return { ok: r.code === 0, code: r.code, saida: r.saida };
310
+ }
311
+
312
+ /** Roda o build.py do projeto HyperFrames. */
313
+ async function build(E, proj, args, { timeout = 3600000 } = {}) {
314
+ const r = await correr(E.python, [path.join(proj, 'build.py'), ...args.map(String)], { cwd: proj, env: ambiente(E.destino, E.python), timeout });
315
+ return { ok: r.code === 0, code: r.code, saida: r.saida };
316
+ }
317
+
318
+ const cauda = (txt, n = 12) => String(txt || '').trim().split('\n').slice(-n).join('\n');
319
+ const lerJson = (p, padrao = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return padrao; } };
320
+ const gravarJson = (p, d) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(d, null, 1)); };
321
+
322
+ function sondar(video) {
323
+ const r = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:stream_side_data=rotation:format=duration', '-of', 'json', video], { encoding: 'utf8', env: ambiente('', '') });
324
+ try {
325
+ const j = JSON.parse(r.stdout);
326
+ const s = (j.streams || [])[0] || {};
327
+ let w = s.width, h = s.height;
328
+ const rot = Math.abs(Number(((s.side_data_list || [])[0] || {}).rotation || 0));
329
+ if (rot === 90 || rot === 270) [w, h] = [h, w];
330
+ return { largura: w, altura: h, duracao: Number(j.format && j.format.duration) || 0 };
331
+ } catch { return { largura: 0, altura: 0, duracao: 0 }; }
332
+ }
333
+
334
+ function slug(t) {
335
+ return String(t || 'video').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'video';
336
+ }
337
+
338
+ /** A pasta de trabalho de uma edição e as gravações dentro dela (o original nunca é tocado: vai uma cópia). */
339
+ function prepararPasta({ video, videos, pasta }, projectDir) {
340
+ const lista = [].concat(videos || [], video || []).filter(Boolean).map((v) => path.resolve(String(v).replace(/^~(?=\/)/, os.homedir())));
341
+ for (const v of lista) if (!fs.existsSync(v)) throw new Error(`não achei a gravação: ${v}`);
342
+ let dir = pasta ? path.resolve(String(pasta).replace(/^~(?=\/)/, os.homedir())) : null;
343
+ if (!dir) {
344
+ if (!lista.length) throw new Error('diga qual gravação editar (video) ou a pasta da edição (pasta)');
345
+ dir = path.join(projectDir || path.join(os.homedir(), 'primocode'), 'edicao-' + slug(path.basename(lista[0]).replace(VIDEO_EXT, '')));
346
+ }
347
+ fs.mkdirSync(dir, { recursive: true });
348
+ const fontes = [];
349
+ for (const v of lista) {
350
+ const destino = path.join(dir, path.basename(v));
351
+ if (path.resolve(v) !== path.resolve(destino) && !fs.existsSync(destino)) {
352
+ try { fs.copyFileSync(v, destino, fs.constants.COPYFILE_FICLONE); } catch { fs.copyFileSync(v, destino); }
353
+ }
354
+ fontes.push(destino);
355
+ }
356
+ if (!fontes.length) {
357
+ const estado = lerJson(path.join(dir, 'edit', 'primocode.json'), {});
358
+ for (const f of estado.fontes || []) if (fs.existsSync(f)) fontes.push(f);
359
+ if (!fontes.length) {
360
+ for (const f of fs.readdirSync(dir)) if (VIDEO_EXT.test(f) && !/^final/i.test(f)) fontes.push(path.join(dir, f));
361
+ }
362
+ }
363
+ if (!fontes.length) throw new Error(`nenhuma gravação em ${dir}`);
364
+ return { pasta: dir, edit: path.join(dir, 'edit'), proj: path.join(dir, 'projeto'), fontes };
365
+ }
366
+
367
+ // ── 4. cortar ────────────────────────────────────────────────────────────
368
+
369
+ /** Trechos sem palavra (barulho, respiro, o "preparando" antes de falar) saem do corte automático. */
370
+ function limparTrechos(ranges) {
371
+ return (ranges || []).filter((r) => {
372
+ const q = String(r.quote || '').trim();
373
+ return q && !/sem transcri/i.test(q);
374
+ });
375
+ }
376
+
377
+ /** O EDL que o modelo pediu: [{inicio, fim, fonte?, frase?}] → ranges no formato do editor. */
378
+ function trechosDoModelo(trechos, fontes) {
379
+ const chave = (f) => path.basename(f).replace(VIDEO_EXT, '');
380
+ return trechos.map((t, i) => {
381
+ const f = t.fonte ? fontes.find((x) => chave(x) === t.fonte || path.basename(x) === t.fonte) || fontes[0] : fontes[0];
382
+ return { id: 'm' + String(i + 1).padStart(2, '0'), source: chave(f), start: Number(t.inicio), end: Number(t.fim), beat: t.parte || '', quote: t.frase || '', reason: 'escolhido pelo modelo', gain_db: 0 };
383
+ });
384
+ }
385
+
386
+ function falaCompacta(edit, max = 60) {
387
+ const tr = lerJson(path.join(edit, 'transcripts', 'cut.json'));
388
+ if (!tr || !Array.isArray(tr.words)) return [];
389
+ const linhas = [];
390
+ let atual = null;
391
+ for (const w of tr.words) {
392
+ const txt = String(w.text || w.word || '').trim();
393
+ if (!txt) continue;
394
+ if (!atual || w.start - atual.fim > 0.6 || atual.palavras >= 12) {
395
+ if (atual) linhas.push(atual);
396
+ atual = { de: w.start, fim: w.end, texto: txt, palavras: 1 };
397
+ } else { atual.texto += ' ' + txt; atual.fim = w.end; atual.palavras++; }
398
+ if (/[.!?]$/.test(txt) && atual) { linhas.push(atual); atual = null; }
399
+ }
400
+ if (atual) linhas.push(atual);
401
+ return linhas.slice(0, max).map((l) => `${l.de.toFixed(2)}-${l.fim.toFixed(2)} ${l.texto}`);
402
+ }
403
+
404
+ async function respirosDe(E, video) {
405
+ const r = await helper(E, 'respiros', [video, '--json']);
406
+ try { return JSON.parse(r.saida.slice(r.saida.indexOf('{'), r.saida.lastIndexOf('}') + 1)); } catch { return null; }
407
+ }
408
+
409
+ /**
410
+ * O corte. opts: { video|videos|pasta, prompt, trechos, grade ('vivo' padrão; '' sem), limpar ('leve'|'forte'|false) }.
411
+ */
412
+ async function cortar(opts = {}, ctx = {}) {
413
+ const dizer = falar(ctx);
414
+ const E = opts.__E || await garantir({ ctx, deps: opts.__deps });
415
+ if (!E.ok) return E;
416
+ const P = prepararPasta(opts, ctx.projectDir);
417
+ fs.mkdirSync(P.edit, { recursive: true });
418
+ const chave = (f) => path.basename(f).replace(VIDEO_EXT, '');
419
+ const prompt = opts.prompt ? ['--prompt', String(opts.prompt)] : [];
420
+ const log = [];
421
+ const passo = async (rotulo, nome, args, extra = {}) => {
422
+ dizer(rotulo);
423
+ const r = await helper(E, nome, args, { ctx, ...extra });
424
+ log.push({ passo: nome, ok: r.ok });
425
+ if (!r.ok) throw new Error(`${rotulo.replace(/…$/, '')} falhou: ${cauda(r.saida, 6)}`);
426
+ return r;
427
+ };
428
+ try {
429
+ const dur = P.fontes.reduce((s, f) => s + sondar(f).duracao, 0);
430
+ for (const f of P.fontes) {
431
+ await passo(`ouvindo a gravação ${path.basename(f)} (${Math.round(dur)} s; na primeira vez baixa o modelo de voz, ~1,6 GB)…`, 'transcribe', [f, '--edit-dir', P.edit, ...prompt], { mostrar: true });
432
+ }
433
+ await passo('juntando as tomadas…', 'pack_transcripts', ['--edit-dir', P.edit]);
434
+ let ranges;
435
+ if (Array.isArray(opts.trechos) && opts.trechos.length) {
436
+ ranges = trechosDoModelo(opts.trechos, P.fontes);
437
+ } else {
438
+ await passo('escolhendo a última tomada inteira de cada frase…', 'montar_corte', ['--edit-dir', P.edit, '--fontes', ...P.fontes]);
439
+ ranges = limparTrechos((lerJson(path.join(P.edit, 'edl.json')) || {}).ranges);
440
+ for (const r of ranges) { r.gain_db = Number(r.gain_db) || 0; }
441
+ }
442
+ if (!ranges.length) return { ok: false, error: 'não achei fala para cortar nessa gravação', pasta: P.pasta };
443
+ const info = sondar(P.fontes[0]);
444
+ const em_pe = info.altura > info.largura;
445
+ const edlManual = {
446
+ version: 1,
447
+ sources: Object.fromEntries(P.fontes.map((f) => [chave(f), f])),
448
+ output: em_pe ? { format: 'vertical', width: 1080, height: 1920 } : { format: 'source', width: info.largura, height: info.altura },
449
+ grade: opts.grade === undefined ? 'vivo' : String(opts.grade || ''),
450
+ jcut: { lead_frames: 4, tail_trim_frames: 2 },
451
+ ranges,
452
+ };
453
+ const manual = path.join(P.edit, 'edl_manual.json');
454
+ gravarJson(manual, edlManual);
455
+ const g = await passo('medindo a voz de cada trecho e acertando o ganho (a voz baixa sobe antes do volume final)…', 'ganho_por_trecho', [manual, '--json']);
456
+ let ganho = null;
457
+ try { ganho = JSON.parse(g.saida.slice(g.saida.indexOf('{'), g.saida.lastIndexOf('}') + 1)); } catch { /* sem relatório */ }
458
+ // respiro é erro: corta pela onda e confere; se sobrou, sobe o limiar (no máximo 3 voltas)
459
+ let limiar = null;
460
+ let resp = null;
461
+ for (let volta = 0; volta < 4; volta++) {
462
+ await passo(volta ? `sobrou respiro: cortando de novo pela onda (limiar ${limiar} dB)…` : 'tirando os respiros pela onda do áudio…', 'corte_por_onda', [manual, '-o', path.join(P.edit, 'edl.json'), ...(limiar != null ? ['--limiar', limiar] : [])]);
463
+ await passo('renderizando o corte…', 'render_cut', [path.join(P.edit, 'edl.json')]);
464
+ resp = await respirosDe(E, path.join(P.edit, 'cut.mp4'));
465
+ if (!resp || !resp.respiros || !resp.respiros.length || volta === 3) break;
466
+ // o limiar do respiros.py vale no corte já com ganho e volume final; na fonte ele fica esse tanto abaixo
467
+ const ln = (lerJson(path.join(P.edit, 'cut_timeline.json'), {}).loudnorm_input) || {};
468
+ const rs = lerJson(manual, {}).ranges || [];
469
+ const ganhoMedio = rs.reduce((s, r) => s + (Number(r.gain_db) || 0), 0) / Math.max(1, rs.length);
470
+ const total = ganhoMedio + (-14 - (Number(ln.input_i) || -14));
471
+ limiar = limiar == null ? Math.round(resp.limiar_db - total + 1) : limiar + 3;
472
+ }
473
+ if (opts.limpar !== false) {
474
+ await passo('limpando o ruído de fundo da voz…', 'voz_limpa', [P.edit, '--modo', opts.limpar === 'forte' ? 'forte' : 'leve']);
475
+ }
476
+ await passo('conferindo o corte (transcreve de novo, mede bordas e respiros)…', 'conferir_corte', [P.edit, ...prompt], { mostrar: true });
477
+ await passo('achando o rosto em cada trecho…', 'face_layout', ['--edit-dir', P.edit, '--force']);
478
+ await passo('medindo o tempo de cada palavra na onda…', 'palavras_na_onda', [P.edit]);
479
+ const tl = lerJson(path.join(P.edit, 'cut_timeline.json'), {});
480
+ const vl = lerJson(path.join(P.edit, 'verify', 'voz_limpa.json'), {});
481
+ const estado = { fontes: P.fontes, cortado_em: new Date().toISOString(), duracao: tl.duration };
482
+ gravarJson(path.join(P.edit, 'primocode.json'), estado);
483
+ return {
484
+ ok: true, pasta: P.pasta, edit: P.edit, corte: path.join(P.edit, 'cut.mp4'),
485
+ duracao: r3(tl.duration || 0), trechos: (lerJson(path.join(P.edit, 'edl.json'), {}).ranges || []).length,
486
+ volume: { ganho_base_db: ganho && ganho.ganho_base_db, voz_antes_lufs: ganho && ganho.referencia_lufs, ajuste: tl.loudnorm_modo, voz_limpa: (vl.relatorio || {}).loudnorm_modo || null },
487
+ respiros: resp ? (resp.respiros || []).length : null,
488
+ fala: falaCompacta(P.edit),
489
+ proximo: 'escreva o plano (gancho, pecas, broll) com os tempos da fala e chame acao:"montar" com a mesma pasta',
490
+ };
491
+ } catch (e) {
492
+ return { ok: false, error: e.message, pasta: P.pasta, passos: log };
493
+ }
494
+ }
495
+
496
+ // ── 5. o plano ───────────────────────────────────────────────────────────
497
+
498
+ const PALAVRAS_FRACAS = new Set('a o e é de da do das dos em no na nos nas um uma uns umas que para pra por com como eu você ele ela isso esse essa este esta hoje vou vai ou se mas'.split(' '));
499
+
500
+ /** O gancho automático: a frase que abre, em até 4 palavras fortes, com a última em destaque. */
501
+ function ganchoAutomatico(fala) {
502
+ const primeira = (fala[0] || '').replace(/^[\d.]+-[\d.]+\s+/, '');
503
+ const palavras = primeira.replace(/[.,!?;:]/g, '').split(/\s+/).filter(Boolean);
504
+ const fortes = palavras.filter((p) => !PALAVRAS_FRACAS.has(p.toLowerCase()) && (p.length > 2 || /^[A-ZÀ-Ú]{2}$/.test(p))).slice(-4);
505
+ if (!fortes.length) return null;
506
+ const ult = fortes.pop();
507
+ return (fortes.length ? fortes.join(' ') + ' ' : '') + `*${ult}*`;
508
+ }
509
+
510
+ function encurtar(texto, max = 22) {
511
+ const ps = String(texto).split(/\s+/);
512
+ let out = '';
513
+ for (const p of ps) { if ((out + ' ' + p).trim().length > max) break; out = (out + ' ' + p).trim(); }
514
+ return out || String(texto).slice(0, max);
515
+ }
516
+
517
+ /** As sugestões do motion_sugerir (fala → peça) no formato do catálogo do projeto HyperFrames. */
518
+ function pecasDasSugestoes(motion) {
519
+ const out = [];
520
+ for (const [i, m] of (motion || []).entries()) {
521
+ const p = m.params || {};
522
+ const t = (k) => r3(m.start + ((p.times || [])[k] || 0));
523
+ if ((m.template === 'checklist' || m.template === 'riscado') && Array.isArray(p.items) && p.items.length >= 2) {
524
+ out.push({ id: 'lista' + i, tipo: 'lista_check', titulo: p.title || undefined, marca: m.template === 'riscado' ? 'x' : 'check', depois_de: r3(m.start - 0.1), sai: r3(m.end), itens: p.items.slice(0, 5).map((it, k) => ({ texto: encurtar(it), em: t(k) })) });
525
+ } else if (m.template === 'passos' && Array.isArray(p.items) && p.items.length >= 2) {
526
+ out.push({ id: 'passos' + i, tipo: 'passo_a_passo', depois_de: r3(m.start - 0.1), sai: r3(m.end), passos: p.items.slice(0, 4).map((it, k) => ({ texto: encurtar(it, 20), em: t(k) })) });
527
+ } else if ((m.template === 'contador' || m.template === 'barra_progresso') && p.value != null) {
528
+ out.push({ id: 'conta' + i, tipo: 'contador', valor_inicial: 0, valor_final: Number(p.value), prefixo: p.prefix || undefined, sufixo: p.suffix ? ' ' + String(p.suffix).trim() : undefined, em: r3(m.start), sai: r3(Math.min(m.end, m.start + 3.5)), duracao: 0.9, casas: Number.isInteger(Number(p.value)) ? 0 : 1 });
529
+ }
530
+ }
531
+ return out;
532
+ }
533
+
534
+ const PECA_GRANDE = new Set(['lista_check', 'passo_a_passo', 'notificacao', 'polaroide', 'hud']);
535
+ const tempoNum = (x) => (typeof x === 'number' ? x : null);
536
+
537
+ /** Monta o projeto.json a partir do plano (do modelo ou automático). */
538
+ function aplicarPlano(cfg, plano, { fala, duracao, broll = [] }) {
539
+ cfg.titulo = plano.titulo || cfg.titulo;
540
+ cfg.formato = plano.formato || cfg.formato || '9:16';
541
+ cfg.estilo = plano.estilo || 'laranja';
542
+ cfg.legenda = Object.assign({ ligada: true, estilo: 'padrao', max_palavras: 3, na_moldura: false }, cfg.legenda || {}, typeof plano.legenda === 'string' ? { estilo: plano.legenda } : (plano.legenda || {}));
543
+ if (plano.correcoes) cfg.correcoes = Object.assign({}, cfg.correcoes || {}, plano.correcoes);
544
+ const animacoes = [];
545
+ const gancho = plano.gancho === false ? null : (plano.gancho || ganchoAutomatico(fala));
546
+ if (gancho) {
547
+ const prim = fala[0] ? Number(fala[0].split('-')[0]) : 0;
548
+ const fimFrase = fala[0] ? Number(fala[0].split(' ')[0].split('-')[1]) : 3;
549
+ const g = typeof gancho === 'object' ? gancho : { principal: gancho };
550
+ animacoes.push(Object.assign({ id: 'gancho', tipo: 'lettering', em: r3(Math.max(0, prim)), sai: r3(Math.min(Math.max(fimFrase, prim + 2.2), prim + 4.5)), posicao: 'topo', estilo: 'bloco' }, g));
551
+ }
552
+ for (const p of plano.pecas || []) animacoes.push(p);
553
+ cfg.animacoes = animacoes;
554
+ if (plano.cenas_proprias) cfg.cenas_proprias = plano.cenas_proprias;
555
+ // blocos: os do plano; senão moldura onde há peça grande e prova/tela verde no b-roll; o resto é tela cheia (câmera viva)
556
+ let blocos = Array.isArray(plano.blocos) ? plano.blocos.slice() : [];
557
+ if (!Array.isArray(plano.blocos)) {
558
+ const vagos = [];
559
+ for (const b of broll) vagos.push(b);
560
+ for (const a of animacoes) {
561
+ if (!PECA_GRANDE.has(a.tipo)) continue;
562
+ const de = tempoNum(a.depois_de) ?? tempoNum(a.em);
563
+ const ate = tempoNum(a.sai);
564
+ if (de == null || ate == null || ate - de < 2) continue;
565
+ vagos.push({ cena: 'moldura', de: r3(Math.max(0, de - 0.15)), ate: r3(Math.min(duracao, ate + 0.25)), fundo: 'desfocado' });
566
+ }
567
+ vagos.sort((a, b) => a.de - b.de);
568
+ for (const b0 of vagos) {
569
+ const b = { ...b0 };
570
+ const ant = blocos[blocos.length - 1];
571
+ // sem encostar: a câmera volta ao rosto entre um e outro (o que sobrar com 1,2 s ou mais fica, mais curto)
572
+ if (ant && b.de < ant.ate + 0.5) b.de = r3(ant.ate + 0.5);
573
+ if (b.de < 1.5) b.de = 1.5; // o gancho fica em tela cheia
574
+ if (b.ate - b.de < 1.2) { (cfg._avisos = cfg._avisos || []).push(`${b.cena} de ${b0.de} a ${b0.ate} ficou de fora: encosta em outro bloco`); continue; }
575
+ if (b.de !== b0.de) (cfg._avisos = cfg._avisos || []).push(`${b.cena} de ${b0.de} começa em ${b.de} (encostava no bloco de antes)`);
576
+ blocos.push(b);
577
+ }
578
+ }
579
+ cfg.blocos = blocos;
580
+ cfg.segura_fim = plano.segura_fim != null ? plano.segura_fim : 1.0;
581
+ if (plano.cartao_final) cfg.cartao_final = plano.cartao_final;
582
+ if (plano.projeto && typeof plano.projeto === 'object') Object.assign(cfg, plano.projeto);
583
+ return cfg;
584
+ }
585
+
586
+ // ── 6. montar ────────────────────────────────────────────────────────────
587
+
588
+ async function buscarBroll(E, P, itens, ctx) {
589
+ const out = [];
590
+ for (const [i, b] of (itens || []).entries()) {
591
+ if (!b || !b.busca || b.de == null || b.ate == null) continue;
592
+ falar(ctx)(`buscando imagem livre para "${b.busca}"…`);
593
+ const r = await helper(E, 'fetch_images', [String(b.busca), '--edit-dir', P.edit, '--count', '1', '--json', ...(b.credito ? ['--allow-attribution'] : [])], { timeout: 120000 });
594
+ let arq = null;
595
+ try {
596
+ const j = JSON.parse(r.saida.slice(r.saida.indexOf('{'), r.saida.lastIndexOf('}') + 1));
597
+ const it = (j.images || [])[0];
598
+ arq = it && it.file ? path.join(P.edit, it.file) : null;
599
+ if (it && it.credit_line) b.credito_linha = it.credit_line;
600
+ } catch { /* sem imagem */ }
601
+ if (!arq || typeof arq !== 'string' || !fs.existsSync(arq)) { out.push({ busca: b.busca, ok: false }); continue; }
602
+ const nome = `broll-${i + 1}${path.extname(arq) || '.jpg'}`;
603
+ fs.mkdirSync(path.join(P.proj, 'assets'), { recursive: true });
604
+ fs.copyFileSync(arq, path.join(P.proj, 'assets', nome));
605
+ const modo = b.modo === 'tela_verde' ? 'tela_verde' : 'prova';
606
+ const bloco = modo === 'tela_verde'
607
+ ? { cena: 'tela_verde', de: Number(b.de), ate: Number(b.ate), fundo: 'assets/' + nome, posicao: b.posicao || 'centro', altura: 0.55, legenda: false }
608
+ : { cena: 'prova', de: Number(b.de), ate: Number(b.ate), arquivo: 'assets/' + nome, encaixe: b.encaixe || 'inteiro', ...(b.rotulo ? { rotulo: b.rotulo } : {}) };
609
+ out.push({ busca: b.busca, ok: true, arquivo: nome, bloco });
610
+ }
611
+ return out;
612
+ }
613
+
614
+ function quadros(video, pastaSaida, nome = 'quadro') {
615
+ const { duracao } = sondar(video);
616
+ fs.mkdirSync(pastaSaida, { recursive: true });
617
+ const out = [];
618
+ for (const pct of [5, 20, 40, 60, 80, 95]) {
619
+ const t = (duracao * pct / 100).toFixed(2);
620
+ const arq = path.join(pastaSaida, `${nome}-${String(pct).padStart(2, '0')}.png`);
621
+ spawnSync('ffmpeg', ['-v', 'error', '-y', '-ss', t, '-i', video, '-frames:v', '1', arq], { env: ambiente('', '') });
622
+ if (fs.existsSync(arq)) out.push(arq);
623
+ }
624
+ return out;
625
+ }
626
+
627
+ function medirAudio(video) {
628
+ const r = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', video, '-vn', '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', env: ambiente('', '') });
629
+ const t = String(r.stderr || '');
630
+ const resumo = t.slice(t.lastIndexOf('Summary:'));
631
+ const num = (re) => { const m = resumo.match(re); return m ? Number(m[1]) : null; };
632
+ const p = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=sample_rate', '-of', 'csv=p=0', video], { encoding: 'utf8', env: ambiente('', '') });
633
+ return { lufs: num(/I:\s+(-?[\d.]+)\s+LUFS/), pico_dbtp: num(/Peak:\s+(-?[\d.]+)\s+dBFS/), hz: Number(String(p.stdout).trim()) || null };
634
+ }
635
+
636
+ /**
637
+ * A montagem. opts: { pasta, plano, trilha ('auto' | arquivo | false), render (true) }.
638
+ */
639
+ async function montar(opts = {}, ctx = {}) {
640
+ const dizer = falar(ctx);
641
+ const precisaEspelho = JSON.stringify(opts.plano || {}).includes('tela_verde');
642
+ const E = opts.__E || await garantir({ ctx, deps: opts.__deps, espelho: precisaEspelho });
643
+ if (!E.ok) return E;
644
+ let P;
645
+ try { P = prepararPasta({ pasta: opts.pasta, video: opts.video }, ctx.projectDir); } catch (e) { return { ok: false, error: e.message }; }
646
+ if (!fs.existsSync(path.join(P.edit, 'cut.mp4'))) return { ok: false, error: 'falta o corte: chame acao:"cortar" antes (ou acao:"editar")', pasta: P.pasta };
647
+ const plano = opts.plano || {};
648
+ const etapas = [];
649
+ const passo = async (rotulo, fn) => {
650
+ dizer(rotulo);
651
+ const r = await fn();
652
+ etapas.push({ etapa: rotulo.replace(/…$/, ''), ok: r.ok });
653
+ if (!r.ok) throw new Error(`${rotulo.replace(/…$/, '')} falhou: ${cauda(r.saida, 8)}`);
654
+ return r;
655
+ };
656
+ try {
657
+ if (!fs.existsSync(path.join(P.proj, 'projeto.json'))) {
658
+ await passo('criando o projeto da edição…', () => correr(E.python, [path.join(E.destino, 'modelo-projeto', 'build.py'), 'novo', P.proj, '--edit', P.edit, '--estilo', plano.estilo || 'laranja'], { cwd: path.join(E.destino, 'modelo-projeto'), env: ambiente(E.destino, E.python) }).then((r) => ({ ok: r.code === 0, saida: r.saida })));
659
+ }
660
+ const fala = falaCompacta(P.edit, 400);
661
+ const tl = lerJson(path.join(P.edit, 'cut_timeline.json'), {});
662
+ let pecasAuto = [];
663
+ if (!Array.isArray(plano.pecas)) {
664
+ const s = await helper(E, 'motion_sugerir', ['--edit-dir', P.edit, '--json']);
665
+ try { pecasAuto = pecasDasSugestoes(JSON.parse(s.saida.trim().split('\n').pop()).motion); } catch { pecasAuto = []; }
666
+ }
667
+ const broll = await buscarBroll(E, P, plano.broll, ctx);
668
+ const cfgPath = path.join(P.proj, 'projeto.json');
669
+ const cfg = lerJson(cfgPath, {});
670
+ aplicarPlano(cfg, { ...plano, pecas: Array.isArray(plano.pecas) ? plano.pecas : pecasAuto }, { fala, duracao: tl.duration || 0, broll: broll.filter((b) => b.ok).map((b) => b.bloco) });
671
+ if (Array.isArray(plano.blocos)) for (const b of broll) if (b.ok) cfg.blocos.push(b.bloco);
672
+ cfg.blocos.sort((a, b) => (tempoNum(a.de) ?? 0) - (tempoNum(b.de) ?? 0));
673
+ cfg.sons = [];
674
+ const avisosPlano = cfg._avisos || [];
675
+ delete cfg._avisos;
676
+ gravarJson(cfgPath, cfg);
677
+ if (cfg.blocos.some((b) => b.cena === 'tela_verde')) {
678
+ for (const b of cfg.blocos.filter((x) => x.cena === 'tela_verde')) {
679
+ await passo(`recortando você do fundo de ${Number(b.de).toFixed(1)} s a ${Number(b.ate).toFixed(1)} s (uns minutos)…`, () => correr(E.python, [path.join(P.proj, 'ferramentas', 'pessoa_recortada.py'), '--edit', P.edit, '--de', Math.max(0, b.de - 0.5).toFixed(2), '--ate', (Number(b.ate) + 0.5).toFixed(2)], { cwd: P.proj, env: ambiente(E.destino, E.python), timeout: 3600000 }).then((r) => ({ ok: r.code === 0, saida: r.saida })));
680
+ }
681
+ }
682
+ if (plano.camera_viva !== false) await passo('pondo a câmera viva (zoom por frase e aproximação na ênfase)…', () => helper(E, 'camera_viva', [P.proj]));
683
+ await passo('montando a edição…', () => build(E, P.proj, ['completo']));
684
+ if (plano.sons !== false) {
685
+ await passo('escolhendo os efeitos sonoros casados com cada animação…', () => helper(E, 'plano_sfx', ['--projeto', P.proj]));
686
+ await passo('montando a edição com os sons…', () => build(E, P.proj, ['completo']));
687
+ }
688
+ const lint = await build(E, P.proj, ['hf', 'lint']);
689
+ if (opts.render === false) return { ok: true, pasta: P.pasta, projeto: P.proj, lint: cauda(lint.saida, 6), broll };
690
+ await passo('renderizando o vídeo (leva alguns minutos)…', () => build(E, P.proj, ['hf', 'render', '--quality', 'delivery', '--output', 'renders/final.mp4'], { timeout: 5400000 }));
691
+ let final = path.join(P.proj, 'renders', 'final.mp4');
692
+ // trilha: arquivo da pessoa, ou a busca do PrimoCode (energia alta), ou nenhuma
693
+ let trilha = null;
694
+ const pedido = plano.trilha !== undefined ? plano.trilha : opts.trilha;
695
+ if (pedido !== false && pedido !== 'nenhuma') {
696
+ if (pedido && pedido !== 'auto' && fs.existsSync(String(pedido))) trilha = { arquivo: String(pedido) };
697
+ else {
698
+ dizer('procurando uma trilha com energia…');
699
+ try {
700
+ const mt = require('./estudio/maquina-trilha.js');
701
+ const t = await mt.escolher({ chave: 'geral', busca: pedido && pedido !== 'auto' ? String(pedido) : null, pasta: P.pasta });
702
+ if (t && t.arquivo) trilha = t;
703
+ } catch { /* sem trilha */ }
704
+ }
705
+ }
706
+ if (trilha) {
707
+ const comTrilha = path.join(P.proj, 'renders', 'final_trilha.mp4');
708
+ await passo('mixando a trilha por baixo da voz (abaixa quando você fala)…', () => helper(E, 'mixar_trilha', ['--video', final, '--trilha', trilha.arquivo, '--abaixo-da-voz', String(plano.trilha_abaixo || 20), '--saida', comTrilha]));
709
+ final = comTrilha;
710
+ }
711
+ dizer('conferindo no portão de qualidade (som, respiros, quadro preto, texto na zona segura)…');
712
+ let portao = await helper(E, 'portao', [final, '--projeto', P.proj, '--json'], { timeout: 1800000 });
713
+ let rel = null;
714
+ try { rel = JSON.parse(portao.saida.slice(portao.saida.indexOf('{'), portao.saida.lastIndexOf('}') + 1)); } catch { /* sem json */ }
715
+ const saida = path.join(P.pasta, 'final.mp4');
716
+ fs.copyFileSync(final, saida);
717
+ if (trilha && trilha.creditos) fs.writeFileSync(path.join(P.pasta, 'final.creditos.txt'), String(trilha.creditos) + '\n');
718
+ const fotos = quadros(saida, path.join(P.pasta, 'quadros'));
719
+ const audio = medirAudio(saida);
720
+ return {
721
+ ok: !!(rel && rel.passou), passou: !!(rel && rel.passou), video: saida, quadros: fotos,
722
+ audio, portao: rel ? { passou: rel.passou, falhas: rel.falhas, modos: rel.modos_do_volume, clipadas: rel.amostras_clipadas, folha: rel.folha, leitor: rel.leitor_de_texto } : cauda(portao.saida),
723
+ trilha: trilha ? { arquivo: trilha.arquivo, titulo: trilha.titulo, creditos: trilha.creditos } : null,
724
+ broll, avisos: avisosPlano, projeto: P.proj, pasta: P.pasta,
725
+ ...(rel && !rel.passou ? { error: 'o portão REPROVOU: ' + (rel.falhas || []).join(' | ') + ' — conserte no plano e chame montar de novo' } : {}),
726
+ };
727
+ } catch (e) {
728
+ return { ok: false, error: e.message, pasta: P.pasta, etapas };
729
+ }
730
+ }
731
+
732
+ /** Corte + montagem de uma vez. */
733
+ async function editar(opts = {}, ctx = {}) {
734
+ const E = await garantir({ ctx, deps: opts.__deps, espelho: JSON.stringify(opts.plano || {}).includes('tela_verde') });
735
+ if (!E.ok) return E;
736
+ const c = await cortar({ ...opts, __E: E }, ctx);
737
+ if (!c.ok) return c;
738
+ const m = await montar({ ...opts, pasta: c.pasta, __E: E }, ctx);
739
+ return { ...m, corte: { duracao: c.duracao, trechos: c.trechos, volume: c.volume, respiros: c.respiros } };
740
+ }
741
+
742
+ // ── 7. a ferramenta e o comando ──────────────────────────────────────────
743
+
744
+ const AJUDA = `editor_pro — edição completa de VÍDEO FALADO (motor EDIT IA PRO)
745
+ acao "editar" {video, prompt?, plano?, trilha?} corte + montagem + portão, de uma vez
746
+ acao "cortar" {video|pasta, prompt?, trechos?} transcreve, corta (ganho por trecho, sem respiro), limpa a voz; devolve a fala com tempos
747
+ acao "montar" {pasta, plano?, trilha?} gancho, peças, b-roll, câmera viva, sons, render, trilha com ducking, portão
748
+ acao "preparar" baixa os arquivos e cria o Python do editor (sem editar nada)
749
+ acao "estado" {pasta} onde a edição parou
750
+ plano: {gancho:"Texto *destaque*", pecas:[peças do catálogo], broll:[{busca,de,ate,rotulo?}], blocos?, estilo?, legenda?, correcoes?, trilha?}`;
751
+
752
+ async function ferramenta(args = {}, ctx = {}) {
753
+ const acao = String(args.acao || 'editar');
754
+ try {
755
+ if (acao === 'ajuda' || acao === 'help') return { ok: true, ajuda: AJUDA };
756
+ if (acao === 'preparar') { const E = await garantir({ ctx, espelho: !!args.espelho }); return E.ok ? { ok: true, editor: E.destino, python: E.python, avisos: E.avisos } : E; }
757
+ if (acao === 'estado') {
758
+ const P = prepararPasta({ pasta: args.pasta, video: args.video }, ctx.projectDir);
759
+ return { ok: true, pasta: P.pasta, cortado: fs.existsSync(path.join(P.edit, 'cut.mp4')), projeto: fs.existsSync(path.join(P.proj, 'projeto.json')), final: fs.existsSync(path.join(P.pasta, 'final.mp4')) ? path.join(P.pasta, 'final.mp4') : null, fala: falaCompacta(P.edit) };
760
+ }
761
+ if (acao === 'cortar') return await cortar(args, ctx);
762
+ if (acao === 'montar') return await montar(args, ctx);
763
+ if (acao === 'editar') return await editar(args, ctx);
764
+ return { ok: false, error: `acao "${acao}" não existe`, ajuda: AJUDA };
765
+ } catch (e) {
766
+ return { ok: false, error: e.message || String(e) };
767
+ }
768
+ }
769
+
770
+ /** `primocode editor <acao> --json '{...}'`: o mesmo, pelo run_shell (servidor antigo sem a ferramenta). */
771
+ async function main(argv = [], { out = process.stdout } = {}) {
772
+ const acao = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'ajuda';
773
+ let args = {};
774
+ const i = argv.indexOf('--json');
775
+ if (i >= 0) { try { args = JSON.parse(argv[i + 1] || '{}'); } catch (e) { out.write(JSON.stringify({ ok: false, error: '--json inválido: ' + e.message }) + '\n'); return 1; } }
776
+ if (acao === 'ajuda' || acao === 'help' || acao === '--help') { out.write(AJUDA + '\n'); return 0; }
777
+ const r = await ferramenta({ ...args, acao }, { projectDir: process.cwd() });
778
+ out.write(JSON.stringify(r) + '\n');
779
+ return r.ok ? 0 : 1;
780
+ }
781
+
782
+ module.exports = {
783
+ ORIGEM, AJUDA, casa, venvDir, venvPython, impressao, instalarArquivos, garantirArquivo, garantirVenv, gerarSintetizados,
784
+ dependencias, acharUv, acharPython, garantir, helper, cortar, montar, editar, ferramenta, main,
785
+ limparTrechos, trechosDoModelo, ganchoAutomatico, pecasDasSugestoes, aplicarPlano, prepararPasta, falaCompacta, urlDoEspelho,
786
+ };