primocode 10.16.0 → 10.17.1

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 (237) 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 +28 -8
  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/mcp.js +105 -7
  222. package/lib/nuvem.js +11 -1
  223. package/lib/outra-pasta.js +1 -1
  224. package/lib/passagem.js +50 -0
  225. package/lib/perigo.js +29 -12
  226. package/lib/protecao.js +134 -0
  227. package/lib/sandbox.js +11 -4
  228. package/lib/titulo-conversa.js +46 -0
  229. package/lib/tools.js +3 -0
  230. package/lib/ui.js +1 -1
  231. package/package.json +3 -2
  232. package/skills/editar-meu-video/SKILL.md +6 -2
  233. package/skills/editar-video/SKILL.md +113 -0
  234. package/studio/pessoa.py +52 -0
  235. package/voz/primo_voz/app.py +43 -4
  236. package/voz/primo_voz/chat.html +17 -6
  237. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,2122 @@
1
+ /* Preview da edição: plain JS, no build step, no external requests. */
2
+ 'use strict';
3
+
4
+ (() => {
5
+ const POLL_MS = 1500;
6
+ const TAB_HASH = { cut: '#corte', style: '#estilo', final: '#final' };
7
+ const HASH_TAB = { '#corte': 'cut', '#estilo': 'style', '#final': 'final' };
8
+ const PHASE_TAB = { setup: 'cut', cut: 'cut', style: 'style', final: 'final' };
9
+ const PHASE_LABEL = { setup: 'Preparação', cut: 'Corte', style: 'Estilo', final: 'Final' };
10
+ const AWAIT_TAB = { approval_cut: 'cut', style: 'style', approval_final: 'final' };
11
+ const SWATCHES = ['#FFC400', '#FF6B35', '#FF3D7F', '#39D98A', '#3DA9FC', '#FFFFFF'];
12
+ const DENOISE = [
13
+ { id: 'off', label: 'Desligado', desc: 'Som do jeito que foi gravado.' },
14
+ { id: 'leve', label: 'Leve', desc: 'Tira chiado e zumbido. A voz fica igual.' },
15
+ { id: 'forte', label: 'Forte', desc: 'Para barulho alto, como rua ou ventilador.' },
16
+ ];
17
+ const DENOISE_IDS = DENOISE.map((d) => d.id);
18
+ // edit_data "motion": how many animations the agent picks and their look
19
+ const MOTION_QTD = [
20
+ { id: 'poucas', n: 3, label: 'Poucas', desc: 'Os 2 ou 3 momentos mais fortes da fala.' },
21
+ { id: 'algumas', n: 5, label: 'Algumas', desc: 'Até 5, espalhadas pelo vídeo.' },
22
+ ];
23
+ // sw = the look's colors (base, highlight, text); null = the accent chosen for the video
24
+ const MOTION_TEMAS = [
25
+ { id: 'papelaria', label: 'Papel lilás', desc: 'Papel claro com base lilás, o estilo da Vit.', sw: ['#c7b7ef', '#665080', '#352c40'] },
26
+ { id: 'papel_azul', label: 'Papel azul', desc: 'Papel claro com base azul e check azul.', sw: ['#a9c7f2', '#2457c5', '#1f2a44'] },
27
+ { id: 'papel_verde', label: 'Papel verde', desc: 'Papel claro com base verde-menta.', sw: ['#a8dcc0', '#1f8a5b', '#1e3a2f'] },
28
+ { id: 'papel_laranja', label: 'Papel laranja', desc: 'Papel claro com base pêssego e laranja forte.', sw: ['#f6c29b', '#e0591f', '#3d2a1e'] },
29
+ { id: 'papel_grafite', label: 'Papel grafite', desc: 'Papel claro com base cinza e vermelho-tijolo. Sóbrio.', sw: ['#c9ccd1', '#d6452b', '#1d1f23'] },
30
+ { id: 'pop', label: 'Pop', desc: 'Cartão creme, contorno preto grosso e sombra dura.', sw: ['#fff8e7', '#e8412c', '#111111'] },
31
+ { id: 'neon', label: 'Neon', desc: 'Cartão escuro com borda que brilha e letra condensada.', sw: ['#0a0a12', '#22d3ee', '#ffffff'] },
32
+ { id: 'escuro', label: 'Vidro escuro', desc: 'Cartão escuro com a cor de destaque do vídeo.', sw: ['#121214', null, '#ffffff'] },
33
+ { id: 'claro', label: 'Branco', desc: 'Cartão branco com a cor de destaque do vídeo.', sw: ['#ffffff', null, '#111111'] },
34
+ ];
35
+ const MOTION_QTD_IDS = MOTION_QTD.map((x) => x.id);
36
+ const MOTION_TEMA_IDS = MOTION_TEMAS.map((x) => x.id);
37
+ const MUSIC_OFF = 'off';
38
+ const MUSIC_FILE = 'arquivo';
39
+ const MUSIC_ASK = ''; // music on, source not chosen yet (older saves, or "music": {"enabled": true} alone)
40
+ const TRILHAS_MS = 10000;
41
+ const MOOD_ID_RE = /^[a-z0-9][a-z0-9_]{0,39}$/;
42
+ const TV_ID = 'tela_verde';
43
+ const TV_POSITIONS = [
44
+ { id: 'esquerda', label: 'Canto esquerdo', desc: 'Você embaixo, à esquerda.' },
45
+ { id: 'direita', label: 'Canto direito', desc: 'Você embaixo, à direita.' },
46
+ { id: 'centro', label: 'Meio', desc: 'Você embaixo, no centro e maior.' },
47
+ ];
48
+ const TV_POS_IDS = TV_POSITIONS.map((x) => x.id);
49
+ const TV_POS_DEFAULT = 'direita';
50
+ const slugify = (text) => String(text || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40).replace(/_+$/, '');
52
+ const baseName = (rel) => String(rel || '').split('/').pop();
53
+ const CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
54
+ const TRASH_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
55
+
56
+ // ---------------------------------------------------------------- helpers
57
+
58
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
59
+
60
+ function el(tag, props = {}, ...children) {
61
+ const node = document.createElement(tag);
62
+ for (const [key, value] of Object.entries(props)) {
63
+ if (value === undefined || value === null || value === false) continue;
64
+ if (key === 'class') node.className = value;
65
+ else if (key === 'text') node.textContent = value;
66
+ else if (key === 'html') node.innerHTML = value;
67
+ else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
68
+ else if (key === 'style') Object.assign(node.style, value);
69
+ else node.setAttribute(key, value === true ? '' : value);
70
+ }
71
+ for (const child of children.flat()) {
72
+ if (child === null || child === undefined || child === false) continue;
73
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
74
+ }
75
+ return node;
76
+ }
77
+
78
+ function fmtTime(seconds) {
79
+ if (seconds === null || seconds === undefined || !isFinite(seconds)) return '--:--';
80
+ const s = Math.max(0, seconds);
81
+ let m = Math.floor(s / 60);
82
+ let rest = Math.round((s - m * 60) * 100) / 100;
83
+ if (rest >= 60) { m += 1; rest -= 60; }
84
+ return `${String(m).padStart(2, '0')}:${rest.toFixed(2).padStart(5, '0')}`;
85
+ }
86
+
87
+ function fmtRuler(seconds, step) {
88
+ const m = Math.floor(seconds / 60);
89
+ const s = seconds - m * 60;
90
+ const secText = step < 1 ? s.toFixed(1).padStart(4, '0') : String(Math.round(s)).padStart(2, '0');
91
+ return `${m}:${secText}`;
92
+ }
93
+
94
+ function fmtClock(value) {
95
+ const d = typeof value === 'number' ? new Date(value) : new Date(Date.parse(value));
96
+ if (isNaN(d)) return '';
97
+ return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
98
+ }
99
+
100
+ function fmtSeconds(sec) {
101
+ return `${(Math.round(sec * 10) / 10).toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`;
102
+ }
103
+
104
+ function firstWords(text, n) {
105
+ if (!text) return '';
106
+ const words = String(text).trim().split(/\s+/);
107
+ const cut = words.slice(0, n).join(' ');
108
+ return words.length > n ? `${cut.replace(/[.,;:!?]+$/, '')}…` : cut;
109
+ }
110
+
111
+ const encodePath = (rel) => String(rel).split('/').map(encodeURIComponent).join('/');
112
+ const versionOf = (info) => (info ? `${Math.round(info.mtime * 1000)}-${info.size}` : '0');
113
+ const mediaUrl = (rel, info) => `/media/${encodePath(rel)}?v=${versionOf(info)}`;
114
+ const parseMs = (iso) => { const t = Date.parse(iso || ''); return isNaN(t) ? 0 : t; };
115
+
116
+ // edit_data "motion": a list of animations or {"enabled": bool, "items": [...]}. undefined = not decided yet.
117
+ function motionOn(m) {
118
+ if (Array.isArray(m)) return m.some((it) => it && typeof it === 'object' && it.enabled !== false) || undefined;
119
+ if (m && typeof m === 'object') {
120
+ if (m.enabled === false) return false;
121
+ return (Array.isArray(m.items) && m.items.some((it) => it && typeof it === 'object' && it.enabled !== false)) || undefined;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ function normalizeHex(value) {
127
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(value || '').trim());
128
+ if (!m) return null;
129
+ let hex = m[1];
130
+ if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
131
+ return `#${hex.toUpperCase()}`;
132
+ }
133
+
134
+ const store = {
135
+ get(key) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : null; } catch { return null; } },
136
+ set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* private mode */ } },
137
+ del(key) { try { localStorage.removeItem(key); } catch { /* ignore */ } },
138
+ dropOthers(prefix, keep) {
139
+ try {
140
+ Object.keys(localStorage).forEach((k) => { if (k.startsWith(prefix) && k !== keep) localStorage.removeItem(k); });
141
+ } catch { /* ignore */ }
142
+ },
143
+ };
144
+
145
+ // The server hands out a per-run token in /api/state; every save must carry it, so no other site can post.
146
+ let previewToken = '';
147
+ let agentListening = false;
148
+ const NUDGE = 'Agora mande uma mensagem na conversa (por exemplo: "pronto") para o agente seguir.';
149
+ const handoff = (listening, saved) => (agentListening ? listening : `${saved} ${NUDGE}`);
150
+
151
+ async function refreshToken() {
152
+ try {
153
+ const res = await fetch('/api/state', { cache: 'no-store' });
154
+ const data = await res.json();
155
+ previewToken = data.token || '';
156
+ agentListening = Boolean(data.watcher && data.watcher.alive);
157
+ } catch { /* offline: the save below reports it */ }
158
+ }
159
+
160
+ async function postJson(path, body, retried = false) {
161
+ if (!previewToken) await refreshToken();
162
+ let res;
163
+ try {
164
+ res = await fetch(path, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json', 'X-Preview-Token': previewToken },
167
+ body: JSON.stringify(body),
168
+ });
169
+ } catch {
170
+ throw new Error('Sem conexão com o preview. Ele ainda está rodando?');
171
+ }
172
+ const data = await res.json().catch(() => ({}));
173
+ if (res.status === 403 && !retried) { previewToken = ''; return postJson(path, body, true); } // server restarted
174
+ if (!res.ok || !data.ok) throw new Error(data.error ? `Não deu para salvar: ${data.error}` : `Não deu para salvar (erro ${res.status}).`);
175
+ return data;
176
+ }
177
+
178
+ function toast(message, kind = 'ok', ms = 3400) {
179
+ const wrap = document.getElementById('toasts');
180
+ const node = el('div', { class: `toast${kind === 'error' ? ' is-error' : ''}`, role: kind === 'error' ? 'alert' : 'status', text: message });
181
+ wrap.append(node);
182
+ while (wrap.children.length > 3) wrap.firstElementChild.remove();
183
+ setTimeout(() => {
184
+ node.classList.add('is-leaving');
185
+ setTimeout(() => node.remove(), 300);
186
+ }, ms);
187
+ }
188
+
189
+ function confirmBox({ title, text, ok }) {
190
+ const dialog = document.getElementById('confirm-dialog');
191
+ dialog.querySelector('[data-confirm-title]').textContent = title;
192
+ dialog.querySelector('[data-confirm-text]').textContent = text;
193
+ dialog.querySelector('[data-confirm-ok]').textContent = ok;
194
+ dialog.returnValue = '';
195
+ return new Promise((resolve) => {
196
+ dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true });
197
+ dialog.showModal();
198
+ dialog.querySelector('[data-confirm-ok]').focus();
199
+ });
200
+ }
201
+
202
+ const isTypingTarget = (target) => {
203
+ if (!target || !target.tagName) return false;
204
+ const tag = target.tagName;
205
+ if (tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) return true;
206
+ return tag === 'INPUT' && !['checkbox', 'radio', 'button', 'submit', 'range', 'color'].includes(target.type);
207
+ };
208
+
209
+ // ---------------------------------------------------------------- review (Corte / Final)
210
+
211
+ class Review {
212
+ constructor(section, cfg) {
213
+ this.cfg = cfg;
214
+ this.section = section;
215
+ section.append(document.getElementById('review-template').content.cloneNode(true));
216
+ const q = (sel) => section.querySelector(sel);
217
+ this.els = {
218
+ empty: q('[data-empty]'), emptyTitle: q('[data-empty-title]'), emptyText: q('[data-empty-text]'),
219
+ review: q('[data-review]'), frame: q('[data-frame]'), video: q('[data-video]'), bigPlay: q('[data-bigplay]'),
220
+ videoMsg: q('[data-video-msg]'), play: q('[data-play]'), time: q('[data-time]'), duration: q('[data-duration]'),
221
+ now: q('[data-now]'), timeline: q('[data-timeline]'), tlScroll: q('[data-tl-scroll]'),
222
+ zoomLevel: q('[data-zoom-level]'), ruler: q('[data-ruler]'), layers: q('[data-layers]'),
223
+ segments: q('[data-segments]'), strip: q('[data-filmstrip]'), stripImg: q('[data-filmstrip-img]'), wave: q('[data-wave]'),
224
+ overlay: q('[data-marks-overlay]'), selection: q('[data-selection]'), playhead: q('[data-playhead]'),
225
+ trimsCard: q('[data-trims-card]'), trimsList: q('[data-trims-list]'), trimCount: q('[data-trim-count]'),
226
+ trimsState: q('[data-trims-state]'), saveTrims: q('[data-save-trims]'), clearTrims: q('[data-clear-trims]'),
227
+ markCount: q('[data-mark-count]'), marksState: q('[data-marks-state]'), setIn: q('[data-set-in]'), setOut: q('[data-set-out]'), motionHere: q('[data-motion-here]'),
228
+ pending: q('[data-pending]'), noteBox: q('[data-note-box]'), noteRange: q('[data-note-range]'), noteInput: q('[data-note-input]'),
229
+ noteCancel: q('[data-note-cancel]'), marksList: q('[data-marks-list]'), marksEmpty: q('[data-marks-empty]'),
230
+ banner: q('[data-banner]'), approveNote: q('[data-approve-note]'), download: q('[data-download]'),
231
+ saveMarks: q('[data-save-marks]'), approve: q('[data-approve]'),
232
+ };
233
+ this.els.waveFill = el('div', { class: 'wave-fill' });
234
+ this.els.wave.append(this.els.waveFill);
235
+ this.els.approve.textContent = cfg.approveLabel;
236
+ this.els.emptyTitle.textContent = cfg.emptyTitle;
237
+ this.els.emptyText.textContent = cfg.emptyText;
238
+ if (cfg.phase === 'final') this.els.layers.hidden = false;
239
+
240
+ this.data = null;
241
+ this.rel = null;
242
+ this.info = null;
243
+ this.mediaKey = null;
244
+ this.candidateKey = null;
245
+ this.duration = 0;
246
+ this.fps = 30;
247
+ this.segments = [];
248
+ this.segKey = '';
249
+ this.layersKey = '';
250
+ this.currentSeg = -1;
251
+ this.marks = [];
252
+ this.dirty = false;
253
+ this.marksSavedAt = null;
254
+ this.trims = new Map(); // id do trecho -> { start_delta, end_delta }
255
+ this.trimsDirty = false;
256
+ this.trimsSavedAt = null;
257
+ this.trimsSeen = null;
258
+ this.dragTrim = null;
259
+ this.pendingIn = null;
260
+ this.noteDraft = null;
261
+ this.scrubbing = false;
262
+ this.scrubTime = 0;
263
+ this.seekTarget = null;
264
+ this.rafOn = false;
265
+ this.active = false;
266
+ this.stripUrl = '';
267
+ this.waveUrl = '';
268
+ this.approving = false;
269
+ this.zoom = 1;
270
+ this.bind();
271
+ }
272
+
273
+ // -- wiring
274
+
275
+ bind() {
276
+ const { video, timeline } = this.els;
277
+ const refresh = () => this.renderTime();
278
+ video.addEventListener('play', () => { this.els.frame.classList.add('is-playing'); this.els.review.classList.add('is-playing'); this.startLoop(); });
279
+ video.addEventListener('pause', () => { this.els.frame.classList.remove('is-playing'); this.els.review.classList.remove('is-playing'); refresh(); });
280
+ video.addEventListener('ended', refresh);
281
+ video.addEventListener('timeupdate', () => { if (!this.rafOn) refresh(); });
282
+ video.addEventListener('seeked', () => this.onSeeked());
283
+ video.addEventListener('loadedmetadata', () => this.onMetadata());
284
+ video.addEventListener('error', () => this.onVideoError());
285
+ video.addEventListener('click', () => this.togglePlay());
286
+ this.els.bigPlay.addEventListener('click', () => this.togglePlay());
287
+ this.els.play.addEventListener('click', () => this.togglePlay());
288
+ this.section.querySelectorAll('[data-step]').forEach((b) => b.addEventListener('click', () => this.stepFrames(Number(b.dataset.step))));
289
+ this.section.querySelectorAll('[data-jump]').forEach((b) => b.addEventListener('click', () => this.jump(Number(b.dataset.jump))));
290
+
291
+ timeline.addEventListener('pointerdown', (e) => this.onPointerDown(e));
292
+ timeline.addEventListener('pointermove', (e) => this.onPointerMove(e));
293
+ timeline.addEventListener('pointerup', (e) => this.onPointerUp(e));
294
+ timeline.addEventListener('pointercancel', (e) => this.onPointerUp(e));
295
+ timeline.addEventListener('lostpointercapture', (e) => this.onPointerUp(e));
296
+
297
+ this.els.setIn.addEventListener('click', () => this.setIn());
298
+ this.els.setOut.addEventListener('click', () => this.setOut());
299
+ if (this.els.motionHere) this.els.motionHere.addEventListener('click', () => this.motionHere());
300
+ this.els.noteBox.addEventListener('submit', (e) => { e.preventDefault(); this.addMark(); });
301
+ this.els.noteCancel.addEventListener('click', () => { this.closeNote(); this.renderTime(); });
302
+ this.els.noteInput.addEventListener('keydown', (e) => {
303
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.closeNote(); this.renderTime(); }
304
+ if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); this.addMark(); }
305
+ });
306
+ this.els.saveMarks.addEventListener('click', () => this.saveMarks());
307
+ this.els.approve.addEventListener('click', () => this.approve());
308
+ this.els.saveTrims.addEventListener('click', () => this.saveTrims());
309
+ this.els.clearTrims.addEventListener('click', () => {
310
+ if (!this.trims.size) return;
311
+ this.trims.clear();
312
+ this.trimsDirty = true;
313
+ this.renderSegments();
314
+ this.renderTrims();
315
+ });
316
+
317
+ this.section.querySelectorAll('[data-zoom]').forEach((b) => b.addEventListener('click', () => {
318
+ const kind = b.dataset.zoom;
319
+ if (kind === 'fit') this.setZoom(1);
320
+ else this.zoomBy(kind === 'in' ? 1 : -1);
321
+ }));
322
+ this.els.tlScroll.addEventListener('wheel', (e) => {
323
+ if (e.ctrlKey || e.metaKey) {
324
+ e.preventDefault();
325
+ this.zoomBy(e.deltaY < 0 ? 1 : -1, e.clientX);
326
+ } else if (this.zoom > 1 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
327
+ e.preventDefault();
328
+ this.els.tlScroll.scrollLeft += e.deltaY;
329
+ }
330
+ }, { passive: false });
331
+
332
+ let resizeTimer = null;
333
+ new ResizeObserver(() => {
334
+ clearTimeout(resizeTimer);
335
+ resizeTimer = setTimeout(() => { this.renderRuler(); this.loadStrips(); }, 180);
336
+ }).observe(timeline);
337
+
338
+ this.applyZoom();
339
+ }
340
+
341
+ // -- zoom da linha do tempo
342
+
343
+ zoomBy(dir, anchorClientX) {
344
+ const steps = [1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32];
345
+ const i = steps.findIndex((s) => s >= this.zoom - 1e-6);
346
+ const at = i < 0 ? steps.length - 1 : i;
347
+ const next = steps[clamp(at + dir, 0, steps.length - 1)];
348
+ this.setZoom(next, anchorClientX);
349
+ }
350
+
351
+ setZoom(z, anchorClientX) {
352
+ const next = clamp(z, 1, 32);
353
+ if (Math.abs(next - this.zoom) < 1e-6) return;
354
+ const { tlScroll, timeline } = this.els;
355
+ const box = tlScroll.getBoundingClientRect();
356
+ // o que fica parado na tela: o ponto do cursor ou, sem cursor, a agulha
357
+ let anchorPx;
358
+ let anchorT;
359
+ if (typeof anchorClientX === 'number') {
360
+ anchorPx = clamp(anchorClientX - box.left, 0, box.width);
361
+ anchorT = this.duration
362
+ ? ((tlScroll.scrollLeft + anchorPx) / Math.max(1, timeline.clientWidth)) * this.duration
363
+ : 0;
364
+ } else {
365
+ anchorT = this.now();
366
+ const x = this.duration ? (anchorT / this.duration) * timeline.clientWidth - tlScroll.scrollLeft : 0;
367
+ anchorPx = x >= 0 && x <= box.width ? x : box.width / 2;
368
+ }
369
+ this.zoom = next;
370
+ this.applyZoom();
371
+ const full = tlScroll.clientWidth * this.zoom;
372
+ tlScroll.scrollLeft = this.duration
373
+ ? clamp((anchorT / this.duration) * full - anchorPx, 0, Math.max(0, full - tlScroll.clientWidth))
374
+ : 0;
375
+ }
376
+
377
+ applyZoom() {
378
+ this.els.timeline.style.width = `${this.zoom * 100}%`;
379
+ this.els.zoomLevel.textContent = this.zoom >= 10 || Number.isInteger(this.zoom)
380
+ ? `${Math.round(this.zoom)}x` : `${this.zoom}x`;
381
+ this.section.querySelectorAll('[data-zoom]').forEach((b) => {
382
+ if (b.dataset.zoom === 'in') b.disabled = this.zoom >= 32;
383
+ if (b.dataset.zoom === 'out') b.disabled = this.zoom <= 1;
384
+ if (b.dataset.zoom === 'fit') b.disabled = this.zoom <= 1;
385
+ });
386
+ this.renderRuler();
387
+ this.loadStrips();
388
+ }
389
+
390
+ // -- esticar trecho (alças nas bordas dos blocos)
391
+
392
+ canTrim() {
393
+ return this.cfg.phase === 'cut';
394
+ }
395
+
396
+ trimOf(id) {
397
+ return this.trims.get(id) || { start_delta: 0, end_delta: 0 };
398
+ }
399
+
400
+ addHandles(block, seg) {
401
+ if (!this.canTrim() || !seg.id) return;
402
+ ['start', 'end'].forEach((side) => {
403
+ const grip = el('button', {
404
+ class: `seg-handle ${side}`,
405
+ type: 'button',
406
+ tabindex: '-1',
407
+ 'aria-label': side === 'start' ? 'Esticar o começo deste trecho' : 'Esticar o fim deste trecho',
408
+ title: side === 'start'
409
+ ? 'Puxe para a esquerda para pegar o começo da palavra (Shift = mais fino, setas = 0,01 s)'
410
+ : 'Puxe para a direita para pegar o fim da palavra (Shift = mais fino, setas = 0,01 s)',
411
+ });
412
+ grip.addEventListener('keydown', (e) => {
413
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
414
+ e.preventDefault();
415
+ e.stopPropagation();
416
+ const step = (e.key === 'ArrowLeft' ? -1 : 1) * (e.shiftKey ? 0.05 : 0.01);
417
+ this.nudgeTrim(seg, side, step);
418
+ });
419
+ grip.addEventListener('pointerdown', (e) => this.onTrimDown(e, seg, side, block, grip));
420
+ grip.addEventListener('pointermove', (e) => this.onTrimMove(e));
421
+ grip.addEventListener('pointerup', (e) => this.onTrimUp(e));
422
+ grip.addEventListener('pointercancel', (e) => this.onTrimUp(e));
423
+ grip.addEventListener('lostpointercapture', (e) => this.onTrimUp(e));
424
+ block.append(grip);
425
+ });
426
+ }
427
+
428
+ onTrimDown(e, seg, side, block, grip) {
429
+ if (e.button !== 0 || !this.duration) return;
430
+ e.preventDefault();
431
+ e.stopPropagation();
432
+ grip.setPointerCapture(e.pointerId);
433
+ grip.focus({ preventScroll: true });
434
+ grip.classList.add('is-on');
435
+ block.classList.add('is-dragging');
436
+ const cur = this.trimOf(seg.id);
437
+ this.dragTrim = {
438
+ seg, side, block, grip,
439
+ x0: e.clientX,
440
+ base: side === 'start' ? cur.start_delta : cur.end_delta,
441
+ delta: side === 'start' ? cur.start_delta : cur.end_delta,
442
+ badge: el('div', { class: 'seg-delta' }),
443
+ };
444
+ block.append(this.dragTrim.badge);
445
+ this.paintDrag();
446
+ }
447
+
448
+ // segundos por pixel de arrasto: acompanha o zoom, mas nunca mais grosso que 0,01 s por pixel.
449
+ // O ajuste aqui é de fração de segundo (o fim de uma palavra), então em 1x a borda não pode
450
+ // correr junto com o mouse: um empurrãozinho viraria 2 s.
451
+ trimRate(fine) {
452
+ const perSec = this.els.timeline.clientWidth / Math.max(0.001, this.duration);
453
+ return Math.min(1 / Math.max(1, perSec), 0.01) / (fine ? 3 : 1);
454
+ }
455
+
456
+ onTrimMove(e) {
457
+ const d = this.dragTrim;
458
+ if (!d) return;
459
+ e.preventDefault();
460
+ const raw = d.base + (e.clientX - d.x0) * this.trimRate(e.shiftKey);
461
+ d.delta = clamp(Math.round(raw * 100) / 100, -2, 2);
462
+ this.paintDrag();
463
+ }
464
+
465
+ nudgeTrim(seg, side, step) {
466
+ const cur = { ...this.trimOf(seg.id) };
467
+ const key = side === 'start' ? 'start_delta' : 'end_delta';
468
+ cur[key] = clamp(Math.round((cur[key] + step) * 100) / 100, -2, 2);
469
+ if (!cur.start_delta && !cur.end_delta) this.trims.delete(seg.id);
470
+ else this.trims.set(seg.id, { ...cur, beat: seg.beat || '', quote: seg.quote || '' });
471
+ this.trimsDirty = true;
472
+ this.renderSegments();
473
+ this.renderTrims();
474
+ const again = [...this.els.segments.children].find((b) => b.dataset.segId === seg.id);
475
+ if (again) {
476
+ const grip = again.querySelector(`.seg-handle.${side}`);
477
+ if (grip) { grip.classList.add('is-on'); grip.focus({ preventScroll: true }); }
478
+ }
479
+ }
480
+
481
+ onTrimUp() {
482
+ const d = this.dragTrim;
483
+ if (!d) return;
484
+ this.dragTrim = null;
485
+ d.grip.classList.remove('is-on');
486
+ d.block.classList.remove('is-dragging');
487
+ d.badge.remove();
488
+ const cur = { ...this.trimOf(d.seg.id) };
489
+ cur[d.side === 'start' ? 'start_delta' : 'end_delta'] = d.delta;
490
+ if (!cur.start_delta && !cur.end_delta) this.trims.delete(d.seg.id);
491
+ else this.trims.set(d.seg.id, { ...cur, beat: d.seg.beat || '', quote: d.seg.quote || '' });
492
+ this.trimsDirty = true;
493
+ this.renderSegments();
494
+ this.renderTrims();
495
+ }
496
+
497
+ paintDrag() {
498
+ const d = this.dragTrim;
499
+ if (!d) return;
500
+ const cur = this.trimOf(d.seg.id);
501
+ const sd = d.side === 'start' ? d.delta : cur.start_delta;
502
+ const ed = d.side === 'end' ? d.delta : cur.end_delta;
503
+ this.placeBlock(d.block, d.seg, sd, ed);
504
+ const seg = d.delta;
505
+ const label = seg === 0 ? 'sem mudança'
506
+ : `${seg > 0 ? '+' : '−'}${Math.abs(seg).toFixed(2).replace('.', ',')} s ${d.side === 'start' ? 'no começo' : 'no fim'}`;
507
+ d.badge.textContent = label;
508
+ }
509
+
510
+ placeBlock(block, seg, sd, ed) {
511
+ const a = seg.out_start + (sd || 0);
512
+ const b = seg.out_end + (ed || 0);
513
+ block.style.left = `calc(${this.pct(a)} + 1px)`;
514
+ block.style.width = `calc(${this.duration ? clamp(((b - a) / this.duration) * 100, 0, 100) : 0}% - 2px)`;
515
+ }
516
+
517
+ renderTrims() {
518
+ const list = this.els.trimsList;
519
+ list.textContent = '';
520
+ const rows = [...this.trims.entries()]
521
+ .map(([id, t]) => ({ id, ...t, seg: this.segments.find((s) => s.id === id) }))
522
+ .sort((a, b) => (a.seg ? a.seg.out_start : 0) - (b.seg ? b.seg.out_start : 0));
523
+ rows.forEach((r) => {
524
+ const bits = [];
525
+ if (r.start_delta) bits.push(`${r.start_delta < 0 ? 'estica' : 'encolhe'} ${Math.abs(r.start_delta).toFixed(2).replace('.', ',')} s no começo`);
526
+ if (r.end_delta) bits.push(`${r.end_delta > 0 ? 'estica' : 'encolhe'} ${Math.abs(r.end_delta).toFixed(2).replace('.', ',')} s no fim`);
527
+ const row = el('li', { class: 'trim-row' },
528
+ el('span', { class: 'trim-when', text: r.seg ? fmtTime(r.seg.out_start) : r.id }),
529
+ el('span', { class: 'trim-what', text: r.quote ? `“${r.quote}”` : (r.beat || r.id), title: r.quote || r.id }),
530
+ el('span', { class: 'trim-delta', text: bits.join(' · ') }));
531
+ const del = el('button', { class: 'icon-btn small danger', type: 'button', 'aria-label': 'Desfazer este ajuste', title: 'Desfazer' });
532
+ del.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
533
+ del.addEventListener('click', () => {
534
+ this.trims.delete(r.id);
535
+ this.trimsDirty = true;
536
+ this.renderSegments();
537
+ this.renderTrims();
538
+ });
539
+ row.append(del);
540
+ list.append(row);
541
+ });
542
+ const n = this.trims.size;
543
+ this.els.trimsCard.hidden = !this.canTrim() || (n === 0 && !this.trimsSavedAt && !this.trimsDirty);
544
+ this.els.trimCount.textContent = n ? String(n) : '';
545
+ const st = this.els.trimsState;
546
+ st.classList.remove('is-dirty', 'is-saved');
547
+ if (this.trimsDirty) {
548
+ st.textContent = n ? 'Não enviados ainda' : 'Alteração não enviada';
549
+ st.classList.add('is-dirty');
550
+ } else if (this.trimsSavedAt) {
551
+ st.textContent = `Enviados às ${fmtClock(this.trimsSavedAt)}`;
552
+ st.classList.add('is-saved');
553
+ } else {
554
+ st.textContent = '';
555
+ }
556
+ this.els.saveTrims.disabled = !this.trimsDirty;
557
+ this.els.saveTrims.classList.toggle('attention', this.trimsDirty && n > 0);
558
+ this.els.clearTrims.disabled = n === 0;
559
+ }
560
+
561
+ async saveTrims() {
562
+ if (!this.trimsDirty) return;
563
+ this.els.saveTrims.disabled = true;
564
+ const payload = [...this.trims.entries()].map(([id, t]) => ({
565
+ id, start_delta: t.start_delta, end_delta: t.end_delta, beat: t.beat || '', quote: t.quote || '',
566
+ }));
567
+ try {
568
+ const res = await postJson('/api/trims', { phase: this.cfg.phase, trims: payload });
569
+ this.trimsDirty = false;
570
+ this.trimsSavedAt = res.saved.saved_at;
571
+ if (this.data && this.data.saved) this.data.saved.trims = res.saved;
572
+ toast(payload.length
573
+ ? handoff('Ajustes enviados. O agente vai esticar e renderizar de novo.', 'Ajustes enviados.')
574
+ : handoff('Ajustes apagados. O agente foi avisado.', 'Ajustes apagados.'),
575
+ 'ok', agentListening ? 3400 : 7000);
576
+ } catch (err) {
577
+ toast(err.message, 'error', 5000);
578
+ }
579
+ this.renderTrims();
580
+ }
581
+
582
+ followPlayhead(t) {
583
+ if (this.zoom <= 1 || this.scrubbing || !this.duration) return;
584
+ const { tlScroll, timeline } = this.els;
585
+ const full = timeline.clientWidth;
586
+ const view = tlScroll.clientWidth;
587
+ if (full <= view) return;
588
+ const x = (t / this.duration) * full;
589
+ const margin = view * 0.15;
590
+ const left = tlScroll.scrollLeft;
591
+ if (x < left + margin || x > left + view - margin) {
592
+ tlScroll.scrollLeft = clamp(x - view / 2, 0, full - view);
593
+ }
594
+ }
595
+
596
+ setActive(active) {
597
+ this.active = active;
598
+ if (!active && !this.els.video.paused) this.els.video.pause();
599
+ if (active) requestAnimationFrame(() => { this.renderRuler(); this.loadStrips(); this.renderTime(); });
600
+ }
601
+
602
+ // -- data from the poll
603
+
604
+ update(data) {
605
+ this.data = data;
606
+ const state = data.state || {};
607
+ const files = data.files || {};
608
+ const rel = this.cfg.phase === 'cut' ? (state.cut_video || 'cut.mp4') : (state.final_video || null);
609
+ const info = rel ? files[rel] : null;
610
+
611
+ if (!rel || !info) {
612
+ this.showEmpty(true);
613
+ return;
614
+ }
615
+ const key = `${rel}|${info.mtime}|${info.size}`;
616
+ if (key !== this.mediaKey) {
617
+ const settled = info.age > 2.5 || key === this.candidateKey;
618
+ if (settled) {
619
+ this.loadMedia(rel, info, key);
620
+ } else {
621
+ this.candidateKey = key;
622
+ if (!this.mediaKey) this.showEmpty(true);
623
+ }
624
+ }
625
+ if (!this.mediaKey) return;
626
+ this.showEmpty(false);
627
+
628
+ const timeline = data.timeline && Array.isArray(data.timeline.segments) ? data.timeline : null;
629
+ if (timeline && timeline.fps) this.fps = Number(timeline.fps) || 30;
630
+ const segKey = JSON.stringify(timeline ? [timeline.duration, timeline.segments] : null);
631
+ if (segKey !== this.segKey) {
632
+ this.segKey = segKey;
633
+ this.segments = timeline ? timeline.segments.slice().sort((a, b) => a.out_start - b.out_start) : [];
634
+ this.renderSegments();
635
+ }
636
+ if (this.cfg.phase === 'final') {
637
+ const layersKey = JSON.stringify([data.edit_data, this.duration]);
638
+ if (layersKey !== this.layersKey) { this.layersKey = layersKey; this.renderLayers(); }
639
+ }
640
+ this.syncTrims(data);
641
+ this.renderBanner();
642
+ }
643
+
644
+ syncTrims(data) {
645
+ if (!this.canTrim()) return;
646
+ const rec = data.saved && data.saved.trims;
647
+ // um ajuste só vale para o render que a pessoa estava vendo: render novo zera a lista
648
+ const fresh = Boolean(rec && rec.phase === this.cfg.phase && this.info
649
+ && parseMs(rec.saved_at) >= this.info.mtime * 1000 - 1);
650
+ const stamp = `${this.mediaKey}|${fresh ? rec.saved_at : ''}`;
651
+ if (stamp === this.trimsSeen) return;
652
+ this.trimsSeen = stamp;
653
+ if (this.trimsDirty) return; // não atropela o que ele está mexendo agora
654
+ this.trims.clear();
655
+ this.trimsSavedAt = fresh ? rec.saved_at : null;
656
+ if (fresh) {
657
+ (rec.trims || []).forEach((t) => this.trims.set(t.id, {
658
+ start_delta: Number(t.start_delta) || 0,
659
+ end_delta: Number(t.end_delta) || 0,
660
+ beat: t.beat || '',
661
+ quote: t.quote || '',
662
+ }));
663
+ }
664
+ this.renderSegments();
665
+ this.renderTrims();
666
+ }
667
+
668
+ showEmpty(isEmpty) {
669
+ this.els.empty.hidden = !isEmpty;
670
+ this.els.review.hidden = isEmpty;
671
+ if (isEmpty && !this.els.video.paused) this.els.video.pause();
672
+ }
673
+
674
+ get draftKey() {
675
+ return `preview:marks:${(this.data && this.data.edit_dir) || ''}:${this.cfg.phase}:${this.mediaKey}`;
676
+ }
677
+
678
+ loadMedia(rel, info, key) {
679
+ const { video } = this.els;
680
+ const hadMedia = Boolean(this.mediaKey);
681
+ const resumeAt = hadMedia ? this.now() : 0;
682
+ const wasPlaying = hadMedia && !video.paused;
683
+ const hadDirtyMarks = this.dirty && this.marks.length > 0;
684
+ this.mediaKey = key;
685
+ this.candidateKey = null;
686
+ this.rel = rel;
687
+ this.info = info;
688
+ this.els.videoMsg.hidden = true;
689
+ this.pendingResume = hadMedia ? { time: resumeAt, play: wasPlaying } : null;
690
+ video.src = mediaUrl(rel, info);
691
+ video.load();
692
+ if (this.cfg.phase === 'final') {
693
+ this.els.download.hidden = false;
694
+ this.els.download.href = mediaUrl(rel, info);
695
+ this.els.download.setAttribute('download', rel.split('/').pop());
696
+ }
697
+ this.stripUrl = '';
698
+ this.waveUrl = '';
699
+ this.showEmpty(false);
700
+
701
+ // marks: keep unsent ones, drop the ones the agent already received
702
+ if (hadMedia) {
703
+ if (!hadDirtyMarks) { this.marks = []; this.dirty = false; this.marksSavedAt = null; }
704
+ this.pendingIn = null;
705
+ this.closeNote();
706
+ toast(this.cfg.reloadedMsg + (hadDirtyMarks ? ' Confira suas marcações antes de salvar.' : ''));
707
+ } else {
708
+ this.restoreMarks();
709
+ }
710
+ this.renderMarks();
711
+ this.renderBanner();
712
+ requestAnimationFrame(() => { this.renderRuler(); this.loadStrips(); });
713
+ }
714
+
715
+ restoreMarks() {
716
+ const saved = this.data && this.data.saved && this.data.saved.marks;
717
+ const draft = store.get(this.draftKey);
718
+ if (draft && Array.isArray(draft.marks)) {
719
+ this.marks = draft.marks;
720
+ this.dirty = true;
721
+ return;
722
+ }
723
+ if (saved && saved.phase === this.cfg.phase && Array.isArray(saved.marks) && parseMs(saved.saved_at) >= this.info.mtime * 1000 - 1) {
724
+ this.marks = saved.marks.map((m) => ({ in: m.in, out: m.out, note: m.note || '', ...(m.kind === 'motion' ? { kind: 'motion' } : {}) }));
725
+ this.dirty = false;
726
+ this.marksSavedAt = saved.saved_at;
727
+ }
728
+ }
729
+
730
+ onMetadata() {
731
+ const { video } = this.els;
732
+ const tlDur = this.data && this.data.timeline ? Number(this.data.timeline.duration) : 0;
733
+ this.duration = isFinite(video.duration) && video.duration > 0 ? video.duration : tlDur;
734
+ this.els.duration.textContent = fmtTime(this.duration);
735
+ if (video.videoWidth && video.videoHeight) {
736
+ this.els.frame.style.setProperty('--ar', `${video.videoWidth} / ${video.videoHeight}`);
737
+ this.els.review.classList.toggle('is-horizontal', video.videoWidth > video.videoHeight * 1.05);
738
+ }
739
+ if (this.pendingResume) {
740
+ const { time, play } = this.pendingResume;
741
+ this.pendingResume = null;
742
+ video.currentTime = clamp(time, 0, Math.max(0, this.duration - 1 / this.fps));
743
+ if (play) video.play().catch(() => {});
744
+ }
745
+ this.renderSegments();
746
+ this.renderLayers();
747
+ this.renderRuler();
748
+ this.renderMarks();
749
+ this.loadStrips();
750
+ this.renderTime();
751
+ }
752
+
753
+ onVideoError() {
754
+ if (!this.mediaKey) return;
755
+ this.els.videoMsg.hidden = false;
756
+ this.els.videoMsg.textContent = 'Não consegui abrir o vídeo. Se o agente ainda está gerando, ele aparece aqui em instantes.';
757
+ const failedKey = this.mediaKey;
758
+ setTimeout(() => {
759
+ if (this.mediaKey === failedKey) { this.mediaKey = null; this.candidateKey = failedKey; }
760
+ }, 2500);
761
+ }
762
+
763
+ // -- playback
764
+
765
+ now() {
766
+ if (this.scrubbing) return this.scrubTime;
767
+ if (this.seekTarget !== null) return this.seekTarget;
768
+ return this.els.video.currentTime || 0;
769
+ }
770
+
771
+ togglePlay() {
772
+ const { video } = this.els;
773
+ if (!this.mediaKey) return;
774
+ if (video.paused || video.ended) {
775
+ if (video.ended || video.currentTime >= this.duration - 0.05) video.currentTime = 0;
776
+ video.play().catch(() => {});
777
+ } else {
778
+ video.pause();
779
+ }
780
+ }
781
+
782
+ seek(t) {
783
+ const { video } = this.els;
784
+ if (!this.duration) return;
785
+ const target = clamp(t, 0, Math.max(0, this.duration - 0.001));
786
+ this.seekTarget = target;
787
+ if (!video.seeking) video.currentTime = target;
788
+ this.renderTime();
789
+ }
790
+
791
+ onSeeked() {
792
+ const { video } = this.els;
793
+ if (this.seekTarget !== null && Math.abs(video.currentTime - this.seekTarget) > 0.0005) {
794
+ video.currentTime = this.seekTarget;
795
+ return;
796
+ }
797
+ this.seekTarget = null;
798
+ this.renderTime();
799
+ }
800
+
801
+ stepFrames(dir) {
802
+ const { video } = this.els;
803
+ video.pause();
804
+ const f = this.fps || 30;
805
+ const idx = Math.round(this.now() * f);
806
+ this.seek((idx + dir) / f + 0.0005);
807
+ }
808
+
809
+ jump(dir) {
810
+ this.seek(this.now() + dir);
811
+ }
812
+
813
+ startLoop() {
814
+ if (this.rafOn) return;
815
+ this.rafOn = true;
816
+ const loop = () => {
817
+ this.renderTime();
818
+ if (!this.els.video.paused || this.scrubbing) requestAnimationFrame(loop);
819
+ else this.rafOn = false;
820
+ };
821
+ requestAnimationFrame(loop);
822
+ }
823
+
824
+ // -- scrubbing
825
+
826
+ timeAtX(clientX) {
827
+ const rect = this.els.timeline.getBoundingClientRect();
828
+ return clamp((clientX - rect.left) / Math.max(1, rect.width), 0, 1) * this.duration;
829
+ }
830
+
831
+ segmentAt(t) {
832
+ return this.segments.find((s) => t >= s.out_start - 1e-3 && t < s.out_end) || null;
833
+ }
834
+
835
+ onPointerDown(e) {
836
+ if (e.button !== 0 || !this.duration) return;
837
+ e.preventDefault();
838
+ const stamp = performance.now();
839
+ const isDouble = this.lastDown && stamp - this.lastDown.t < 400 && Math.abs(e.clientX - this.lastDown.x) < 8;
840
+ this.lastDown = isDouble ? null : { t: stamp, x: e.clientX };
841
+ if (isDouble) {
842
+ const seg = this.segmentAt(this.timeAtX(e.clientX));
843
+ if (seg) { this.markSegment(seg); return; }
844
+ }
845
+ this.els.timeline.setPointerCapture(e.pointerId);
846
+ this.scrubbing = true;
847
+ this.resumeAfterScrub = !this.els.video.paused;
848
+ this.els.video.pause();
849
+ this.scrubTime = this.timeAtX(e.clientX);
850
+ this.seek(this.scrubTime);
851
+ this.startLoop();
852
+ }
853
+
854
+ onPointerMove(e) {
855
+ if (!this.scrubbing) return;
856
+ this.scrubTime = this.timeAtX(e.clientX);
857
+ this.seek(this.scrubTime);
858
+ }
859
+
860
+ onPointerUp() {
861
+ if (!this.scrubbing) return;
862
+ this.scrubbing = false;
863
+ this.seek(this.scrubTime);
864
+ if (this.resumeAfterScrub) this.els.video.play().catch(() => {});
865
+ }
866
+
867
+ // -- rendering
868
+
869
+ pct(t) {
870
+ return this.duration ? `${clamp((t / this.duration) * 100, 0, 100)}%` : '0%';
871
+ }
872
+
873
+ renderTime() {
874
+ const t = this.now();
875
+ this.els.time.textContent = fmtTime(t);
876
+ this.els.playhead.style.left = this.pct(t);
877
+ this.followPlayhead(t);
878
+ if (this.pendingIn !== null || this.noteDraft) {
879
+ const a = this.noteDraft ? this.noteDraft.in : Math.min(this.pendingIn, t);
880
+ const b = this.noteDraft ? this.noteDraft.out : Math.max(this.pendingIn, t);
881
+ this.els.selection.hidden = false;
882
+ this.els.selection.style.left = this.pct(a);
883
+ this.els.selection.style.width = this.duration ? `${((b - a) / this.duration) * 100}%` : '0';
884
+ } else {
885
+ this.els.selection.hidden = true;
886
+ }
887
+ let idx = -1;
888
+ for (let i = 0; i < this.segments.length; i += 1) {
889
+ const s = this.segments[i];
890
+ if (t >= s.out_start - 1e-3 && t < s.out_end) { idx = i; break; }
891
+ }
892
+ if (idx === -1 && this.segments.length && t >= this.segments[this.segments.length - 1].out_start) idx = this.segments.length - 1;
893
+ if (idx !== this.currentSeg) {
894
+ this.currentSeg = idx;
895
+ this.els.segments.querySelectorAll('.seg-block').forEach((b, i) => b.classList.toggle('is-current', i === idx));
896
+ const seg = this.segments[idx];
897
+ this.els.now.textContent = '';
898
+ if (seg) {
899
+ const label = seg.beat || `Trecho ${idx + 1}`;
900
+ this.els.now.append(el('b', { text: label }), seg.quote ? `“${seg.quote}”` : '');
901
+ this.els.now.title = seg.quote || '';
902
+ }
903
+ }
904
+ const items = this.els.marksList.children;
905
+ this.marks.forEach((m, i) => { if (items[i]) items[i].classList.toggle('is-current', t >= m.in && t <= m.out); });
906
+ }
907
+
908
+ renderRuler() {
909
+ const ruler = this.els.ruler;
910
+ ruler.textContent = '';
911
+ const width = this.els.timeline.clientWidth;
912
+ if (!this.duration || !width) return;
913
+ const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
914
+ const pxPerSec = width / this.duration;
915
+ const step = steps.find((s) => s * pxPerSec >= 90) || 600;
916
+ const minor = step / (step >= 10 ? 5 : 2);
917
+ const frag = document.createDocumentFragment();
918
+ for (let t = 0; t <= this.duration + 1e-6; t += minor) {
919
+ const isMajor = Math.abs(t / step - Math.round(t / step)) < 1e-6;
920
+ const tick = el('div', { class: isMajor ? 'tick' : 'tick minor' });
921
+ tick.style.left = this.pct(t);
922
+ if (isMajor && (this.duration - t) * pxPerSec > 28) tick.textContent = fmtRuler(t, step);
923
+ frag.append(tick);
924
+ }
925
+ ruler.append(frag);
926
+ }
927
+
928
+ renderSegments() {
929
+ const box = this.els.segments;
930
+ box.textContent = '';
931
+ this.currentSeg = -2;
932
+ if (!this.duration) return;
933
+ const segs = this.segments.length ? this.segments : [{ out_start: 0, out_end: this.duration, beat: 'Vídeo inteiro', quote: '' }];
934
+ segs.forEach((s, i) => {
935
+ const label = s.beat || `Trecho ${i + 1}`;
936
+ const block = el('div', {
937
+ class: 'seg-block',
938
+ title: `${label} · ${fmtTime(s.out_start)} → ${fmtTime(s.out_end)}${s.quote ? `\n“${s.quote}”` : ''}\n2 cliques marcam este trecho inteiro`,
939
+ }, el('span', { class: 'seg-beat', text: label }), s.quote ? el('span', { class: 'seg-quote', text: firstWords(s.quote, 6) }) : null);
940
+ if (s.id) block.dataset.segId = s.id;
941
+ const tr = s.id ? this.trimOf(s.id) : null;
942
+ if (tr && (tr.start_delta || tr.end_delta)) block.classList.add('is-trimmed');
943
+ this.placeBlock(block, s, tr ? tr.start_delta : 0, tr ? tr.end_delta : 0);
944
+ this.addHandles(block, s);
945
+ box.append(block);
946
+ });
947
+ this.renderTime();
948
+ }
949
+
950
+ renderLayers() {
951
+ if (this.cfg.phase !== 'final') return;
952
+ const box = this.els.layers;
953
+ box.textContent = '';
954
+ const ed = this.data && this.data.edit_data;
955
+ if (!ed || !this.duration) { box.hidden = true; return; }
956
+ const items = [];
957
+ if (ed.hook && ed.hook.enabled !== false && ed.hook.end > ed.hook.start) items.push({ start: ed.hook.start, end: ed.hook.end, label: 'Gancho' });
958
+ const insertLabel = { split_top: 'Imagem em cima', split_bottom: 'Imagem embaixo', full: 'Tela cheia', pip: 'Janela' };
959
+ if (ed.edit_type === TV_ID) { insertLabel.split_top = 'Fundo'; insertLabel.split_bottom = 'Fundo'; }
960
+ (ed.inserts || []).forEach((ins) => {
961
+ if (ins.end > ins.start) items.push({ start: ins.start, end: ins.end, label: insertLabel[ins.type] || 'Imagem' });
962
+ });
963
+ if (ed.cta && ed.cta.enabled !== false) {
964
+ const dur = Number(ed.cta.duration) || 2.5;
965
+ const start = ed.cta.start === null || ed.cta.start === undefined ? this.duration - dur : Number(ed.cta.start);
966
+ items.push({ start, end: Math.min(this.duration, start + dur), label: 'Chamada' });
967
+ }
968
+ box.hidden = items.length === 0;
969
+ items.forEach((it) => {
970
+ const pill = el('div', { class: 'layer-pill', text: it.label, title: `${it.label} · ${fmtTime(it.start)} → ${fmtTime(it.end)}` });
971
+ pill.style.left = this.pct(it.start);
972
+ pill.style.width = `max(6px, ${((it.end - it.start) / this.duration) * 100}%)`;
973
+ box.append(pill);
974
+ });
975
+ }
976
+
977
+ loadStrips() {
978
+ if (!this.rel || !this.info || !this.active) return;
979
+ const width = this.els.timeline.clientWidth;
980
+ if (!width) return;
981
+ const dpr = clamp(window.devicePixelRatio || 1, 1, 2);
982
+ const { video } = this.els;
983
+ const aspect = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 9 / 16;
984
+ const stripH = this.els.strip.clientHeight || 64;
985
+ const thumbW = stripH * aspect;
986
+ const n = clamp(Math.ceil(width / thumbW / 4) * 4, 4, 160);
987
+ const h = clamp(Math.round((stripH * dpr) / 16) * 16, 32, 192);
988
+ const ver = versionOf(this.info);
989
+ const file = encodeURIComponent(this.rel);
990
+ const stripUrl = `/api/filmstrip?file=${file}&n=${n}&h=${h}&v=${ver}`;
991
+ if (stripUrl !== this.stripUrl) {
992
+ this.stripUrl = stripUrl;
993
+ const { strip, stripImg } = this.els;
994
+ strip.classList.add('is-loading');
995
+ const img = new Image();
996
+ img.onload = () => { if (this.stripUrl === stripUrl) { stripImg.src = stripUrl; strip.classList.remove('is-loading', 'is-empty'); } };
997
+ img.onerror = () => { if (this.stripUrl === stripUrl) { strip.classList.remove('is-loading'); strip.classList.add('is-empty'); } };
998
+ img.src = stripUrl;
999
+ }
1000
+ const w = clamp(Math.round((width * dpr) / 250) * 250, 500, 4000);
1001
+ const waveUrl = `/api/waveform?file=${file}&w=${w}&h=104&v=${ver}`;
1002
+ if (waveUrl !== this.waveUrl) {
1003
+ this.waveUrl = waveUrl;
1004
+ const { wave, waveFill } = this.els;
1005
+ wave.classList.add('is-loading');
1006
+ const img = new Image();
1007
+ img.onload = () => {
1008
+ if (this.waveUrl !== waveUrl) return;
1009
+ waveFill.style.webkitMaskImage = `url("${waveUrl}")`;
1010
+ waveFill.style.maskImage = `url("${waveUrl}")`;
1011
+ wave.classList.remove('is-loading');
1012
+ wave.classList.add('is-ready');
1013
+ };
1014
+ img.onerror = () => { if (this.waveUrl === waveUrl) wave.classList.remove('is-loading'); };
1015
+ img.src = waveUrl;
1016
+ }
1017
+ }
1018
+
1019
+ // -- marks
1020
+
1021
+ setIn() {
1022
+ if (!this.mediaKey) return;
1023
+ this.closeNote();
1024
+ this.pendingIn = this.now();
1025
+ this.els.pending.hidden = false;
1026
+ this.els.pending.textContent = `Início em ${fmtTime(this.pendingIn)}. Agora vá até o fim do trecho e marque o fim.`;
1027
+ this.renderTime();
1028
+ }
1029
+
1030
+ setOut() {
1031
+ if (!this.mediaKey) return;
1032
+ const t = this.now();
1033
+ let a = this.pendingIn;
1034
+ let b = t;
1035
+ let auto = false;
1036
+ if (a === null) { a = Math.max(0, t - 1); auto = true; }
1037
+ if (b < a) [a, b] = [b, a];
1038
+ if (b - a < 0.1) b = Math.min(this.duration, a + 0.5);
1039
+ this.els.video.pause();
1040
+ this.pendingIn = null;
1041
+ this.els.pending.hidden = true;
1042
+ this.noteDraft = { in: Math.round(a * 1000) / 1000, out: Math.round(b * 1000) / 1000 };
1043
+ this.els.noteRange.textContent = `${fmtTime(a)} → ${fmtTime(b)}`;
1044
+ this.els.noteRange.append(el('small', { text: auto ? `${fmtSeconds(b - a)}, o segundo antes deste ponto` : fmtSeconds(b - a) }));
1045
+ this.els.noteBox.hidden = false;
1046
+ this.els.noteInput.value = '';
1047
+ this.renderTime();
1048
+ requestAnimationFrame(() => this.els.noteInput.focus());
1049
+ }
1050
+
1051
+ markSegment(seg) {
1052
+ if (!this.mediaKey) return;
1053
+ this.els.video.pause();
1054
+ this.closeNote();
1055
+ this.pendingIn = null;
1056
+ this.els.pending.hidden = true;
1057
+ const a = Math.round(seg.out_start * 1000) / 1000;
1058
+ const b = Math.round(Math.min(this.duration, seg.out_end) * 1000) / 1000;
1059
+ this.seek(a);
1060
+ this.noteDraft = { in: a, out: b };
1061
+ this.els.noteRange.textContent = `${fmtTime(a)} → ${fmtTime(b)}`;
1062
+ this.els.noteRange.append(el('small', { text: `${fmtSeconds(b - a)}, o trecho inteiro` }));
1063
+ this.els.noteBox.hidden = false;
1064
+ this.els.noteInput.value = '';
1065
+ this.renderTime();
1066
+ requestAnimationFrame(() => this.els.noteInput.focus());
1067
+ }
1068
+
1069
+ closeNote() {
1070
+ this.noteDraft = null;
1071
+ this.els.noteBox.hidden = true;
1072
+ }
1073
+
1074
+ cancelPending() {
1075
+ const had = this.pendingIn !== null || !this.els.noteBox.hidden;
1076
+ this.pendingIn = null;
1077
+ this.els.pending.hidden = true;
1078
+ this.closeNote();
1079
+ this.renderTime();
1080
+ return had;
1081
+ }
1082
+
1083
+ motionHere() {
1084
+ if (!this.mediaKey) return;
1085
+ this.els.video.pause();
1086
+ this.cancelPending();
1087
+ const t = Math.round(this.now() * 1000) / 1000;
1088
+ this.marks.push({ in: t, out: Math.round(Math.min(this.duration || t + 1, t + 1) * 1000) / 1000, note: '', kind: 'motion' });
1089
+ this.marks.sort((x, y) => x.in - y.in || x.out - y.out);
1090
+ this.markDirty();
1091
+ toast(`Motion pedido em ${fmtTime(t)}. Se quiser, escreva o que mostrar e envie as marcações.`, 'ok', 3200);
1092
+ }
1093
+
1094
+ addMark() {
1095
+ if (!this.noteDraft) return;
1096
+ this.marks.push({ ...this.noteDraft, note: this.els.noteInput.value.trim() });
1097
+ this.marks.sort((x, y) => x.in - y.in || x.out - y.out);
1098
+ this.closeNote();
1099
+ this.els.noteInput.blur();
1100
+ this.markDirty();
1101
+ }
1102
+
1103
+ deleteMark(index) {
1104
+ this.marks.splice(index, 1);
1105
+ this.markDirty();
1106
+ }
1107
+
1108
+ markDirty() {
1109
+ this.dirty = true;
1110
+ store.set(this.draftKey, { marks: this.marks });
1111
+ store.dropOthers(`preview:marks:${(this.data && this.data.edit_dir) || ''}:${this.cfg.phase}:`, this.draftKey);
1112
+ this.renderMarks();
1113
+ }
1114
+
1115
+ renderMarks() {
1116
+ const list = this.els.marksList;
1117
+ list.textContent = '';
1118
+ const overlay = this.els.overlay;
1119
+ overlay.textContent = '';
1120
+ this.marks.forEach((m, i) => {
1121
+ const range = el('button', { class: 'mark-range', type: 'button', title: 'Ir para este trecho', text: `${fmtTime(m.in)} → ${fmtTime(m.out)}` });
1122
+ range.addEventListener('click', () => { this.els.video.pause(); this.seek(m.in); });
1123
+ const isMotion = m.kind === 'motion';
1124
+ const note = el('input', {
1125
+ class: 'mark-note', type: 'text', maxlength: '500',
1126
+ placeholder: isMotion ? 'Opcional: o que mostrar (ex.: lista com os 3 itens)' : 'Sem nota. Escreva o que mudar',
1127
+ 'aria-label': `Nota da marcação ${i + 1}`,
1128
+ });
1129
+ note.value = m.note || '';
1130
+ note.addEventListener('input', () => { m.note = note.value; this.dirty = true; store.set(this.draftKey, { marks: this.marks }); this.renderMarksState(); });
1131
+ note.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === 'Escape') note.blur(); });
1132
+ const del = el('button', { class: 'icon-btn small danger', type: 'button', 'aria-label': `Apagar marcação ${i + 1}`, title: 'Apagar marcação', html: TRASH_SVG });
1133
+ del.addEventListener('click', () => this.deleteMark(i));
1134
+ const num = el('span', { class: isMotion ? 'mark-num is-motion' : 'mark-num', text: isMotion ? '✦' : String(i + 1), title: isMotion ? 'Motion aqui' : '' });
1135
+ list.append(el('li', { class: isMotion ? 'mark-item is-motion' : 'mark-item' }, num, range, note, del));
1136
+
1137
+ const region = el('div', { class: isMotion ? 'mark-region is-motion' : 'mark-region' }, el('span', { text: isMotion ? '✦' : String(i + 1) }));
1138
+ region.style.left = this.pct(m.in);
1139
+ region.style.width = this.duration ? `max(2px, ${((m.out - m.in) / this.duration) * 100}%)` : '0';
1140
+ overlay.append(region);
1141
+ });
1142
+ this.els.marksEmpty.hidden = this.marks.length > 0;
1143
+ this.els.markCount.textContent = this.marks.length ? `(${this.marks.length})` : '';
1144
+ this.renderMarksState();
1145
+ this.renderTime();
1146
+ }
1147
+
1148
+ renderMarksState() {
1149
+ const st = this.els.marksState;
1150
+ st.classList.remove('is-dirty', 'is-saved');
1151
+ if (this.dirty) {
1152
+ st.textContent = this.marks.length ? 'Não enviadas ainda' : 'Alteração não enviada';
1153
+ st.classList.add('is-dirty');
1154
+ } else if (this.marksSavedAt) {
1155
+ st.textContent = `Enviadas às ${fmtClock(this.marksSavedAt)}`;
1156
+ st.classList.add('is-saved');
1157
+ } else {
1158
+ st.textContent = '';
1159
+ }
1160
+ this.els.saveMarks.disabled = !this.dirty;
1161
+ this.els.saveMarks.classList.toggle('attention', this.dirty && this.marks.length > 0);
1162
+ }
1163
+
1164
+ async saveMarks() {
1165
+ if (!this.dirty) return;
1166
+ if (!this.els.noteBox.hidden && this.noteDraft) this.addMark();
1167
+ this.els.saveMarks.disabled = true;
1168
+ try {
1169
+ const res = await postJson('/api/marks', { phase: this.cfg.phase, marks: this.marks });
1170
+ this.dirty = false;
1171
+ this.marksSavedAt = res.saved.saved_at;
1172
+ this.marks = res.saved.marks.map((m) => ({ ...m }));
1173
+ store.del(this.draftKey);
1174
+ if (this.data && this.data.saved) this.data.saved.marks = res.saved;
1175
+ toast(this.marks.length ? handoff(this.cfg.marksSavedMsg, 'Marcações salvas.')
1176
+ : handoff('Marcações apagadas. O agente foi avisado.', 'Marcações apagadas.'), 'ok', agentListening ? 3400 : 7000);
1177
+ this.renderMarks();
1178
+ this.renderBanner();
1179
+ } catch (err) {
1180
+ toast(err.message, 'error', 5000);
1181
+ this.renderMarksState();
1182
+ }
1183
+ }
1184
+
1185
+ isApproved() {
1186
+ const ap = this.data && this.data.saved && this.data.saved.approval;
1187
+ return Boolean(ap && ap.approved && ap.phase === this.cfg.phase && this.info && parseMs(ap.saved_at) >= this.info.mtime * 1000 - 1);
1188
+ }
1189
+
1190
+ async approve() {
1191
+ if (this.approving || !this.mediaKey) return;
1192
+ if (this.trimsDirty && this.trims.size) {
1193
+ const go = await confirmBox({
1194
+ title: 'Aprovar sem enviar os ajustes?',
1195
+ text: 'Você puxou a borda de algum trecho e ainda não clicou em "Enviar ajustes". Se aprovar agora, o agente não vai esticar esses trechos.',
1196
+ ok: 'Aprovar mesmo assim',
1197
+ });
1198
+ if (!go) return;
1199
+ }
1200
+ const savedMarks = this.data && this.data.saved && this.data.saved.marks;
1201
+ if (this.dirty && this.marks.length) {
1202
+ const go = await confirmBox({
1203
+ title: 'Aprovar sem enviar as marcações?',
1204
+ text: 'Você mexeu nas marcações e ainda não clicou em "Salvar marcações". Se aprovar agora, o agente não vai ver essas mudanças.',
1205
+ ok: 'Aprovar mesmo assim',
1206
+ });
1207
+ if (!go) return;
1208
+ } else if (savedMarks && savedMarks.phase === this.cfg.phase && Array.isArray(savedMarks.marks) && savedMarks.marks.length
1209
+ && parseMs(savedMarks.saved_at) >= this.info.mtime * 1000 - 1) {
1210
+ const go = await confirmBox({
1211
+ title: 'Aprovar com marcações enviadas?',
1212
+ text: 'Você enviou marcações para esta versão e o agente ainda não mandou uma versão nova. Se aprovar, ele vai perguntar se ajusta antes de seguir.',
1213
+ ok: 'Aprovar mesmo assim',
1214
+ });
1215
+ if (!go) return;
1216
+ }
1217
+ this.approving = true;
1218
+ this.els.approve.disabled = true;
1219
+ try {
1220
+ const res = await postJson('/api/approve', {
1221
+ phase: this.cfg.phase, note: this.els.approveNote.value.trim(),
1222
+ file: this.rel, file_mtime: this.info.mtime, file_size: this.info.size,
1223
+ });
1224
+ if (this.data && this.data.saved) this.data.saved.approval = res.saved;
1225
+ this.els.approveNote.value = '';
1226
+ toast(handoff(this.cfg.approvedMsg, this.cfg.approvedShort), 'ok', agentListening ? 3400 : 7000);
1227
+ } catch (err) {
1228
+ toast(err.message, 'error', 5000);
1229
+ } finally {
1230
+ this.approving = false;
1231
+ this.renderBanner();
1232
+ }
1233
+ }
1234
+
1235
+ renderBanner() {
1236
+ const { banner, approve, approveNote } = this.els;
1237
+ if (!this.info) return;
1238
+ const approved = this.isApproved();
1239
+ const savedMarks = this.data && this.data.saved && this.data.saved.marks;
1240
+ banner.className = 'banner';
1241
+ if (approved) {
1242
+ const ap = this.data.saved.approval;
1243
+ banner.hidden = false;
1244
+ banner.classList.add('is-approved');
1245
+ banner.textContent = this.cfg.approvedBanner(fmtClock(ap.saved_at), this.rel);
1246
+ } else if (!this.dirty && savedMarks && savedMarks.phase === this.cfg.phase && savedMarks.marks.length
1247
+ && parseMs(savedMarks.saved_at) >= this.info.mtime * 1000 - 1) {
1248
+ banner.hidden = false;
1249
+ banner.classList.add('is-info');
1250
+ banner.textContent = this.cfg.marksBanner(fmtClock(savedMarks.saved_at));
1251
+ } else {
1252
+ banner.hidden = true;
1253
+ }
1254
+ approve.textContent = approved ? this.cfg.approvedLabel : this.cfg.approveLabel;
1255
+ approve.classList.toggle('is-done', approved);
1256
+ approve.disabled = approved || this.approving;
1257
+ approveNote.hidden = approved;
1258
+ }
1259
+
1260
+ // -- keyboard
1261
+
1262
+ handleKey(e) {
1263
+ if (this.els.review.hidden) return false;
1264
+ const key = e.key;
1265
+ if (e.code === 'Space' || key === ' ') { if (!e.repeat) this.togglePlay(); return true; }
1266
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
1267
+ const dir = key === 'ArrowLeft' ? -1 : 1;
1268
+ if (e.shiftKey) this.jump(dir); else this.stepFrames(dir);
1269
+ return true;
1270
+ }
1271
+ if (key === 'i' || key === 'I') { this.setIn(); return true; }
1272
+ if (key === 'o' || key === 'O') { this.setOut(); return true; }
1273
+ if (key === 'm' || key === 'M') { this.motionHere(); return true; }
1274
+ if (key === '+' || key === '=') { this.zoomBy(1); return true; }
1275
+ if (key === '-' || key === '_') { this.zoomBy(-1); return true; }
1276
+ if (key === '0') { this.setZoom(1); return true; }
1277
+ if (key === 'Escape') return this.cancelPending();
1278
+ return false;
1279
+ }
1280
+ }
1281
+
1282
+ // ---------------------------------------------------------------- style tab
1283
+
1284
+ class StyleView {
1285
+ constructor(section) {
1286
+ this.section = section;
1287
+ const q = (sel) => section.querySelector(sel);
1288
+ this.els = {
1289
+ empty: q('[data-style-empty]'), main: q('[data-style-main]'), types: q('[data-edit-types]'),
1290
+ picker: q('[data-accent-picker]'), hex: q('[data-accent-hex]'), hexHelp: q('[data-hex-help]'), swatches: q('[data-swatches]'),
1291
+ hookText: q('[data-hook-text]'), hookCount: q('[data-hook-count]'), hooks: q('[data-hook-styles]'), captions: q('[data-caption-styles]'),
1292
+ elements: q('[data-elements]'), denoise: q('[data-denoise]'), music: q('[data-music]'), musicHelp: q('[data-music-help]'),
1293
+ tvPos: q('[data-tv-pos]'), tvPosList: q('[data-tv-pos-list]'), tvNote: q('[data-tv-note]'),
1294
+ cta: q('[data-cta-text]'), ctaHelp: q('[data-cta-help]'), note: q('[data-style-note]'),
1295
+ pvImg: q('[data-pv-img]'), pvPlaceholder: q('[data-pv-placeholder]'), pvLabel: q('[data-pv-label]'), sampleNote: q('[data-sample-note]'),
1296
+ summary: q('[data-summary]'), saved: q('[data-style-saved]'), save: q('[data-save-style]'),
1297
+ };
1298
+ this.data = null;
1299
+ this.trilhas = null;
1300
+ this.trilhasKey = '';
1301
+ this.catalog = null;
1302
+ this.catalogKey = '';
1303
+ this.samplesKey = '';
1304
+ this.picks = null;
1305
+ this.baseKey = '';
1306
+ this.dirty = false;
1307
+ this.savedAt = null;
1308
+ this.pv = 'hook';
1309
+ this.hover = null;
1310
+ this.saving = false;
1311
+ this.bind();
1312
+ }
1313
+
1314
+ bind() {
1315
+ const { picker, hex, hookText, cta, note, save } = this.els;
1316
+ picker.addEventListener('input', () => this.setAccent(picker.value, 'picker'));
1317
+ hex.addEventListener('input', () => {
1318
+ const norm = normalizeHex(hex.value);
1319
+ const full = /^#?[0-9a-f]{6}$/i.test(hex.value.trim());
1320
+ if (norm && full) { this.setAccent(norm, 'hex'); this.hexError(false); } else { this.hexError(hex.value.trim().length >= 7 || !/^#?[0-9a-f]*$/i.test(hex.value.trim())); }
1321
+ });
1322
+ hex.addEventListener('blur', () => {
1323
+ const norm = normalizeHex(hex.value);
1324
+ if (norm) this.setAccent(norm, 'blur');
1325
+ else if (this.picks) hex.value = this.picks.accent;
1326
+ this.hexError(false);
1327
+ });
1328
+ hookText.addEventListener('input', () => { this.picks.hook_text = hookText.value; this.touch(); this.renderCounter(); });
1329
+ cta.addEventListener('input', () => { this.picks.cta_text = cta.value; this.touch(); });
1330
+ note.addEventListener('input', () => { this.picks.note = note.value; this.touch(); });
1331
+ save.addEventListener('click', () => this.save());
1332
+ this.section.querySelectorAll('[data-pv]').forEach((b) => b.addEventListener('click', () => { this.pv = b.dataset.pv; this.renderPreview(); }));
1333
+ SWATCHES.forEach((c) => {
1334
+ const sw = el('button', { class: 'swatch', type: 'button', title: c, 'aria-label': `Usar a cor ${c}`, 'aria-pressed': 'false', style: { background: c } });
1335
+ sw.addEventListener('click', () => this.setAccent(c, 'swatch'));
1336
+ this.els.swatches.append(sw);
1337
+ });
1338
+ }
1339
+
1340
+ get draftKey() { return `preview:style:${(this.data && this.data.edit_dir) || ''}`; }
1341
+
1342
+ update(data) {
1343
+ this.data = data;
1344
+ const cat = data.catalog;
1345
+ const usable = cat && ((cat.caption_styles || []).length || (cat.hook_styles || []).length || (cat.edit_types || []).length);
1346
+ this.els.empty.hidden = Boolean(usable);
1347
+ this.els.main.hidden = !usable;
1348
+ if (!usable) return;
1349
+
1350
+ const catalogKey = JSON.stringify(cat);
1351
+ const catalogChanged = catalogKey !== this.catalogKey;
1352
+ if (catalogChanged) { this.catalogKey = catalogKey; this.catalog = cat; }
1353
+
1354
+ const saved = data.saved && data.saved.style;
1355
+ if (saved && saved.saved_at) this.savedAt = saved.saved_at;
1356
+ const base = this.basePicks(data);
1357
+ const baseKey = JSON.stringify(base);
1358
+ if (!this.picks) {
1359
+ const draft = store.get(this.draftKey);
1360
+ if (draft && draft.picks && draft.base === baseKey) { this.picks = this.sanitize(draft.picks); this.dirty = true; } else { this.picks = base; }
1361
+ this.baseKey = baseKey;
1362
+ this.renderAll();
1363
+ } else if (baseKey !== this.baseKey) {
1364
+ this.baseKey = baseKey;
1365
+ if (!this.dirty) { this.picks = base; this.renderAll(); }
1366
+ else if (catalogChanged) this.renderAll();
1367
+ } else if (catalogChanged) {
1368
+ this.picks = this.sanitize(this.picks);
1369
+ this.renderAll();
1370
+ }
1371
+
1372
+ const samplesRel = (data.state && data.state.samples) || 'samples/index.json';
1373
+ const samplesInfo = data.files ? data.files[samplesRel] : null;
1374
+ const samplesKey = JSON.stringify([data.samples, versionOf(samplesInfo)]);
1375
+ if (samplesKey !== this.samplesKey) {
1376
+ this.samplesKey = samplesKey;
1377
+ this.renderCards();
1378
+ this.renderPreview();
1379
+ }
1380
+ if (this.els.tvNote && this.picks && this.picks.edit_type === TV_ID) this.els.tvNote.textContent = this.tvNoteText();
1381
+ this.renderSaved();
1382
+ }
1383
+
1384
+ basePicks(data) {
1385
+ const cat = data.catalog || {};
1386
+ const d = cat.defaults || {};
1387
+ const first = (list) => (list && list[0] ? list[0].id : null);
1388
+ const p = {
1389
+ edit_type: d.edit_type || first(cat.edit_types),
1390
+ caption_style: d.caption_style || first(cat.caption_styles),
1391
+ hook_style: d.hook_style || first(cat.hook_styles),
1392
+ accent: normalizeHex(d.accent) || '#FFC400',
1393
+ hook_text: '',
1394
+ cta_text: '',
1395
+ elements: {},
1396
+ denoise: 'off',
1397
+ music_mood: MUSIC_OFF,
1398
+ music_file: '',
1399
+ tela_verde_posicao: TV_POS_DEFAULT,
1400
+ motion_qtd: 'poucas',
1401
+ motion_tema: 'papelaria',
1402
+ note: '',
1403
+ };
1404
+ (cat.elements || []).forEach((item) => { p.elements[item.id] = Boolean(item.default); });
1405
+ const ed = data.edit_data;
1406
+ if (ed && typeof ed === 'object') {
1407
+ if (normalizeHex(ed.accent)) p.accent = normalizeHex(ed.accent);
1408
+ if (ed.audio && ed.audio.enabled !== false && DENOISE_IDS.includes(ed.audio.denoise)) p.denoise = ed.audio.denoise;
1409
+ if (ed.hook) { if (ed.hook.style) p.hook_style = ed.hook.style; if (ed.hook.text) p.hook_text = ed.hook.text; }
1410
+ if (ed.captions && ed.captions.style) p.caption_style = ed.captions.style;
1411
+ if (ed.cta && ed.cta.text) p.cta_text = ed.cta.text;
1412
+ if (ed.edit_type === TV_ID) p.edit_type = TV_ID;
1413
+ if (ed.tela_verde && TV_POS_IDS.includes(ed.tela_verde.posicao)) p.tela_verde_posicao = ed.tela_verde.posicao;
1414
+ if (ed.motion && typeof ed.motion === 'object' && !Array.isArray(ed.motion)) {
1415
+ if (MOTION_TEMA_IDS.includes(ed.motion.tema)) p.motion_tema = ed.motion.tema;
1416
+ if (Number(ed.motion.quantidade) > 0) p.motion_qtd = Number(ed.motion.quantidade) <= 3 ? 'poucas' : 'algumas';
1417
+ }
1418
+ const fromEd = {
1419
+ zoom_cuts: ed.camera && ed.camera.zoom_cuts, push_in: ed.camera && ed.camera.push_in,
1420
+ flash: Array.isArray(ed.transitions) ? ed.transitions.some((t) => t.type === 'flash') : undefined,
1421
+ sfx: ed.sfx && ed.sfx.enabled, music: ed.music && ed.music.enabled,
1422
+ cta: ed.cta && ed.cta.enabled, disclaimer: ed.disclaimer && ed.disclaimer.enabled,
1423
+ motion: motionOn(ed.motion),
1424
+ };
1425
+ Object.entries(fromEd).forEach(([k, v]) => { if (typeof v === 'boolean' && k in p.elements) p.elements[k] = v; });
1426
+ const mu = ed.music;
1427
+ if (mu && typeof mu === 'object') {
1428
+ if (mu.enabled === false) p.music_mood = MUSIC_OFF;
1429
+ else if (typeof mu.file === 'string' && mu.file) { p.music_mood = MUSIC_FILE; p.music_file = mu.file; }
1430
+ else if (typeof mu.mood === 'string' && slugify(mu.mood)) p.music_mood = slugify(mu.mood);
1431
+ else if (Object.keys(mu).length) p.music_mood = MUSIC_ASK;
1432
+ }
1433
+ }
1434
+ const merge = (src) => {
1435
+ if (!src || typeof src !== 'object') return;
1436
+ ['edit_type', 'caption_style', 'hook_style'].forEach((k) => { if (src[k]) p[k] = src[k]; });
1437
+ if (normalizeHex(src.accent)) p.accent = normalizeHex(src.accent);
1438
+ ['hook_text', 'cta_text', 'note'].forEach((k) => { if (typeof src[k] === 'string') p[k] = src[k]; });
1439
+ if (src.elements && typeof src.elements === 'object') Object.entries(src.elements).forEach(([k, v]) => { p.elements[k] = Boolean(v); });
1440
+ if (DENOISE_IDS.includes(src.denoise)) p.denoise = src.denoise;
1441
+ if (TV_POS_IDS.includes(src.tela_verde_posicao)) p.tela_verde_posicao = src.tela_verde_posicao;
1442
+ if (MOTION_QTD_IDS.includes(src.motion_qtd)) p.motion_qtd = src.motion_qtd;
1443
+ if (MOTION_TEMA_IDS.includes(src.motion_tema)) p.motion_tema = src.motion_tema;
1444
+ if (typeof src.music_mood === 'string' && (src.music_mood === MUSIC_OFF || MOOD_ID_RE.test(src.music_mood))) {
1445
+ p.music_mood = src.music_mood;
1446
+ p.music_file = src.music_mood === MUSIC_FILE && typeof src.music_file === 'string' ? src.music_file : '';
1447
+ } else if (src.elements && typeof src.elements.music === 'boolean') {
1448
+ // saved before the Trilha choice existed: keep "com trilha" without inventing a source
1449
+ if (!src.elements.music) p.music_mood = MUSIC_OFF;
1450
+ else if (p.music_mood === MUSIC_OFF) p.music_mood = MUSIC_ASK;
1451
+ }
1452
+ };
1453
+ merge(data.state && data.state.style);
1454
+ merge(data.saved && data.saved.style);
1455
+ return this.sanitize(p, cat);
1456
+ }
1457
+
1458
+ sanitize(p, cat = this.catalog || {}) {
1459
+ const ids = (list) => (list || []).map((x) => x.id);
1460
+ const fix = (value, list, fallback) => (ids(list).includes(value) ? value : (fallback && ids(list).includes(fallback) ? fallback : (ids(list)[0] || null)));
1461
+ const d = cat.defaults || {};
1462
+ const out = { ...p, elements: { ...(p.elements || {}) } };
1463
+ out.edit_type = fix(out.edit_type, cat.edit_types, d.edit_type);
1464
+ out.caption_style = fix(out.caption_style, cat.caption_styles, d.caption_style);
1465
+ out.hook_style = fix(out.hook_style, cat.hook_styles, d.hook_style);
1466
+ out.accent = normalizeHex(out.accent) || '#FFC400';
1467
+ ['hook_text', 'cta_text', 'note'].forEach((k) => { out[k] = typeof out[k] === 'string' ? out[k] : ''; });
1468
+ (cat.elements || []).forEach((item) => { if (!(item.id in out.elements)) out.elements[item.id] = Boolean(item.default); });
1469
+ out.denoise = DENOISE_IDS.includes(out.denoise) ? out.denoise : 'off';
1470
+ out.tela_verde_posicao = TV_POS_IDS.includes(out.tela_verde_posicao) ? out.tela_verde_posicao : TV_POS_DEFAULT;
1471
+ out.motion_qtd = MOTION_QTD_IDS.includes(out.motion_qtd) ? out.motion_qtd : 'poucas';
1472
+ out.motion_tema = MOTION_TEMA_IDS.includes(out.motion_tema) ? out.motion_tema : 'papelaria';
1473
+ out.music_file = typeof out.music_file === 'string' ? out.music_file : '';
1474
+ if (typeof out.music_mood !== 'string' || !(out.music_mood === MUSIC_OFF || out.music_mood === MUSIC_ASK || MOOD_ID_RE.test(out.music_mood))) out.music_mood = MUSIC_OFF;
1475
+ if (out.music_mood === MUSIC_FILE && !out.music_file) out.music_mood = MUSIC_OFF;
1476
+ if (out.music_mood !== MUSIC_FILE) out.music_file = '';
1477
+ const t = this && this.trilhas;
1478
+ if (t && t.ok) {
1479
+ // a mood or a file that the library or the edit no longer has: still "com trilha", source to choose again
1480
+ const moods = (t.moods || []).map((m) => m.id);
1481
+ if (out.music_mood === MUSIC_FILE && !(t.edit_files || []).includes(out.music_file)) out.music_mood = MUSIC_ASK;
1482
+ else if (![MUSIC_OFF, MUSIC_FILE, MUSIC_ASK].includes(out.music_mood) && !moods.includes(out.music_mood)) out.music_mood = MUSIC_ASK;
1483
+ if (out.music_mood !== MUSIC_FILE) out.music_file = '';
1484
+ }
1485
+ if ('music' in out.elements || out.music_mood !== MUSIC_OFF) out.elements.music = out.music_mood !== MUSIC_OFF;
1486
+ return out;
1487
+ }
1488
+
1489
+ setTrilhas(t) {
1490
+ const key = JSON.stringify(t);
1491
+ if (key === this.trilhasKey) return;
1492
+ this.trilhasKey = key;
1493
+ this.trilhas = t;
1494
+ if (!this.picks || !this.catalog) return;
1495
+ this.picks = this.sanitize(this.picks);
1496
+ this.renderMusic();
1497
+ this.renderSummary();
1498
+ }
1499
+
1500
+ touch() {
1501
+ this.dirty = true;
1502
+ store.set(this.draftKey, { picks: this.picks, base: this.baseKey });
1503
+ this.renderSummary();
1504
+ this.renderSaved();
1505
+ }
1506
+
1507
+ // -- rendering
1508
+
1509
+ renderAll() {
1510
+ const { hookText, cta, note } = this.els;
1511
+ this.renderTypes();
1512
+ this.renderCards();
1513
+ this.renderElements();
1514
+ this.renderMusic();
1515
+ this.renderDenoise();
1516
+ hookText.value = this.picks.hook_text || '';
1517
+ cta.value = this.picks.cta_text || '';
1518
+ note.value = this.picks.note || '';
1519
+ this.setAccent(this.picks.accent, 'init');
1520
+ this.renderCounter();
1521
+ this.renderPreview();
1522
+ this.renderSummary();
1523
+ this.renderSaved();
1524
+ }
1525
+
1526
+ renderTypes() {
1527
+ const box = this.els.types;
1528
+ box.textContent = '';
1529
+ (this.catalog.edit_types || []).forEach((t) => {
1530
+ const schema = el('span', { class: t.id === TV_ID ? `schema tv pos-${this.picks.tela_verde_posicao}` : 'schema', 'aria-hidden': 'true' });
1531
+ if (t.id === 'split_top') schema.append(el('span', { class: 'band' }), el('span', { class: 'person' }));
1532
+ else if (t.id === 'split_bottom') schema.append(el('span', { class: 'person' }), el('span', { class: 'band' }));
1533
+ else schema.append(el('span', { class: 'person' }));
1534
+ const card = el('button', {
1535
+ class: 'type-card', type: 'button', role: 'radio', 'aria-checked': String(this.picks.edit_type === t.id), 'data-id': t.id,
1536
+ 'aria-label': t.desc ? `${t.label || t.id}. ${t.desc}` : (t.label || t.id),
1537
+ },
1538
+ schema, el('span', { class: 'tc-text' }, el('strong', { text: t.label || t.id }), t.desc ? el('span', { class: 'tc-desc', text: t.desc }) : null));
1539
+ card.addEventListener('click', () => { this.picks.edit_type = t.id; this.syncChecked(box, t.id); this.renderTvPos(); this.touch(); });
1540
+ box.append(card);
1541
+ });
1542
+ this.renderTvPos();
1543
+ }
1544
+
1545
+ tvAvailable() {
1546
+ return (this.catalog.edit_types || []).some((t) => t.id === TV_ID);
1547
+ }
1548
+
1549
+ tvPosLabel() {
1550
+ const pos = TV_POSITIONS.find((x) => x.id === this.picks.tela_verde_posicao) || TV_POSITIONS[1];
1551
+ return pos.label;
1552
+ }
1553
+
1554
+ tvNoteText() {
1555
+ // the inserts already planned stay in edit_data: in tela verde the split ones become the background by themselves
1556
+ const ed = this.data && this.data.edit_data;
1557
+ const list = ed && Array.isArray(ed.inserts) ? ed.inserts : [];
1558
+ const n = list.filter((x) => x && (x.type === 'split_top' || x.type === 'split_bottom')).length;
1559
+ if (n === 1) return 'A imagem ou vídeo já planejado vai para o fundo, atrás de você. Não precisa mudar nada.';
1560
+ if (n > 1) return `As ${n} imagens e vídeos já planejados vão para o fundo, atrás de você. Não precisa mudar nada.`;
1561
+ return 'As imagens e vídeos da edição vão para o fundo, atrás de você.';
1562
+ }
1563
+
1564
+ renderTvPos() {
1565
+ const { tvPos, tvPosList, tvNote } = this.els;
1566
+ if (!tvPos || !tvPosList) return;
1567
+ const on = this.picks.edit_type === TV_ID && this.tvAvailable();
1568
+ tvPos.hidden = !on;
1569
+ tvPosList.textContent = '';
1570
+ if (tvNote) tvNote.textContent = on ? this.tvNoteText() : '';
1571
+ if (!on) return;
1572
+ TV_POSITIONS.forEach((pos) => {
1573
+ const mini = el('span', { class: `schema tv pos-${pos.id}`, 'aria-hidden': 'true' }, el('span', { class: 'person' }));
1574
+ const card = el('button', {
1575
+ class: 'type-card tv-pos-card', type: 'button', role: 'radio', 'data-id': pos.id,
1576
+ 'aria-checked': String(this.picks.tela_verde_posicao === pos.id), 'aria-label': `${pos.label}. ${pos.desc}`,
1577
+ }, mini, el('span', { class: 'tc-text' }, el('strong', { text: pos.label }), el('span', { class: 'tc-desc', text: pos.desc })));
1578
+ card.addEventListener('click', () => {
1579
+ this.picks.tela_verde_posicao = pos.id;
1580
+ this.syncChecked(tvPosList, pos.id);
1581
+ const main = this.els.types.querySelector(`[data-id="${TV_ID}"] .schema`);
1582
+ if (main) main.className = `schema tv pos-${pos.id}`;
1583
+ this.touch();
1584
+ });
1585
+ tvPosList.append(card);
1586
+ });
1587
+ }
1588
+
1589
+ sampleUrl(kind, id) {
1590
+ const idx = this.data && this.data.samples;
1591
+ const file = idx && idx[kind] && idx[kind][id];
1592
+ if (!file) return null;
1593
+ const samplesRel = (this.data.state && this.data.state.samples) || 'samples/index.json';
1594
+ const dir = samplesRel.includes('/') ? samplesRel.slice(0, samplesRel.lastIndexOf('/') + 1) : '';
1595
+ const rel = file.startsWith('/') ? null : `${dir}${file}`;
1596
+ if (!rel) return null;
1597
+ return mediaUrl(rel, this.data.files ? this.data.files[samplesRel] : null);
1598
+ }
1599
+
1600
+ renderCards() {
1601
+ if (!this.catalog || !this.picks) return;
1602
+ const build = (box, list, kind, pickKey) => {
1603
+ box.textContent = '';
1604
+ (list || []).forEach((st) => {
1605
+ const url = this.sampleUrl(kind, st.id);
1606
+ const thumb = el('span', { class: 'thumb' });
1607
+ if (url) {
1608
+ const img = el('img', { src: url, alt: '', loading: 'lazy', draggable: 'false' });
1609
+ img.addEventListener('error', () => { img.remove(); thumb.append(el('span', { class: 'thumb-fallback', text: st.label || st.id })); });
1610
+ thumb.append(img);
1611
+ } else {
1612
+ thumb.append(el('span', { class: 'thumb-fallback', text: st.label || st.id }));
1613
+ }
1614
+ const card = el('button', {
1615
+ class: 'style-card', type: 'button', role: 'radio', 'aria-checked': String(this.picks[pickKey] === st.id),
1616
+ 'data-id': st.id, title: st.desc || '',
1617
+ 'aria-label': [st.label || st.id, kind === 'caption' && st.animated ? 'animada' : '', st.desc || ''].filter(Boolean).join('. '),
1618
+ },
1619
+ kind === 'caption' && st.animated ? el('span', { class: 'badge', text: 'Animada' }) : null,
1620
+ el('span', { class: 'check', html: CHECK_SVG }),
1621
+ thumb,
1622
+ el('span', { class: 'meta' }, el('strong', { text: st.label || st.id }), st.desc ? el('span', { text: st.desc }) : null));
1623
+ card.addEventListener('click', () => {
1624
+ this.picks[pickKey] = st.id;
1625
+ this.pv = kind;
1626
+ this.syncChecked(box, st.id);
1627
+ this.touch();
1628
+ this.renderPreview();
1629
+ });
1630
+ card.addEventListener('mouseenter', () => { this.hover = { kind, id: st.id }; this.renderPreview(); });
1631
+ card.addEventListener('mouseleave', () => { this.hover = null; this.renderPreview(); });
1632
+ box.append(card);
1633
+ });
1634
+ };
1635
+ build(this.els.hooks, this.catalog.hook_styles, 'hook', 'hook_style');
1636
+ build(this.els.captions, this.catalog.caption_styles, 'caption', 'caption_style');
1637
+ }
1638
+
1639
+ renderElements() {
1640
+ const box = this.els.elements;
1641
+ box.textContent = '';
1642
+ (this.catalog.elements || []).forEach((item) => {
1643
+ if (item.id === 'music') return; // the "Trilha de fundo" section below is the control for it
1644
+ const input = el('input', { class: 'switch', type: 'checkbox', 'data-id': item.id, 'aria-label': item.label || item.id });
1645
+ input.checked = Boolean(this.picks.elements[item.id]);
1646
+ input.addEventListener('change', () => {
1647
+ this.picks.elements[item.id] = input.checked;
1648
+ this.touch();
1649
+ this.renderCtaState();
1650
+ if (item.id === 'motion') this.renderMotionOptions();
1651
+ });
1652
+ box.append(el('label', { class: 'toggle' }, el('span', { text: item.label || item.id }), input));
1653
+ if (item.id === 'motion') box.append(el('div', { class: 'motion-options', 'data-motion-options': '' }));
1654
+ });
1655
+ this.renderCtaState();
1656
+ this.renderMotionOptions();
1657
+ }
1658
+
1659
+ renderMotionOptions() {
1660
+ const box = this.els.elements.querySelector('[data-motion-options]');
1661
+ if (!box) return;
1662
+ box.textContent = '';
1663
+ box.hidden = !this.picks.elements.motion;
1664
+ if (box.hidden) return;
1665
+ const group = (title, opts, key) => {
1666
+ const list = el('div', { class: 'motion-choice', role: 'radiogroup', 'aria-label': title });
1667
+ opts.forEach((opt) => {
1668
+ const card = el('button', {
1669
+ class: 'type-card motion-card', type: 'button', role: 'radio', 'data-id': opt.id,
1670
+ 'aria-checked': String(this.picks[key] === opt.id), 'aria-label': `${opt.label}. ${opt.desc}`,
1671
+ }, ...(opt.sw ? [el('span', { class: 'motion-sw', 'aria-hidden': 'true' },
1672
+ ...opt.sw.map((c) => { const dot = el('i'); dot.style.background = c || this.picks.accent; return dot; }))] : []),
1673
+ el('span', { class: 'tc-text' }, el('strong', { text: opt.label }), el('span', { class: 'tc-desc', text: opt.desc })));
1674
+ card.addEventListener('click', () => { this.picks[key] = opt.id; this.syncChecked(list, opt.id); this.touch(); });
1675
+ list.append(card);
1676
+ });
1677
+ return el('div', { class: 'motion-group' }, el('span', { class: 'motion-label', text: title }), list);
1678
+ };
1679
+ box.append(
1680
+ group('Quantas', MOTION_QTD, 'motion_qtd'),
1681
+ group('Visual', MOTION_TEMAS, 'motion_tema'),
1682
+ el('p', { class: 'hint', text: 'O agente acha os momentos mais fortes da fala. Quer num lugar certo? Na aba do vídeo, pare no ponto e toque em "Motion aqui".' }),
1683
+ );
1684
+ }
1685
+
1686
+ musicOptions() {
1687
+ const t = this.trilhas;
1688
+ const opts = [{ key: MUSIC_OFF, mood: MUSIC_OFF, file: '', mark: '–', label: 'Sem trilha', desc: 'Só a sua voz e os efeitos.' }];
1689
+ if (t && t.ok) {
1690
+ (t.moods || []).forEach((m) => {
1691
+ const count = `${m.count} ${m.count === 1 ? 'trilha' : 'trilhas'}`;
1692
+ const fav = m.favorite_title ? ` · ★ ${m.favorite_title}` : ' · o agente escolhe a que encaixa melhor';
1693
+ opts.push({ key: m.id, mood: m.id, file: '', mark: '♪', label: m.label || m.id, desc: `${count}${fav}`, title: m.when || '' });
1694
+ });
1695
+ (t.edit_files || []).forEach((f) => {
1696
+ opts.push({ key: `${MUSIC_FILE}:${f}`, mood: MUSIC_FILE, file: f, mark: '♫', label: 'Usar arquivo da pasta', desc: baseName(f), title: f });
1697
+ });
1698
+ }
1699
+ return opts;
1700
+ }
1701
+
1702
+ musicKey() {
1703
+ return this.picks.music_mood === MUSIC_FILE ? `${MUSIC_FILE}:${this.picks.music_file}` : this.picks.music_mood;
1704
+ }
1705
+
1706
+ musicLabel() {
1707
+ const opt = this.musicOptions().find((o) => o.key === this.musicKey());
1708
+ if (opt) return opt.mood === MUSIC_FILE ? `Arquivo ${opt.desc}` : opt.label;
1709
+ if (this.picks.music_mood === MUSIC_ASK) return 'Com trilha, falta escolher';
1710
+ return this.picks.music_mood === MUSIC_FILE ? `Arquivo ${baseName(this.picks.music_file)}` : this.picks.music_mood;
1711
+ }
1712
+
1713
+ renderMusic() {
1714
+ const box = this.els.music;
1715
+ if (!box || !this.picks) return;
1716
+ box.textContent = '';
1717
+ const opts = this.musicOptions();
1718
+ const current = this.musicKey();
1719
+ if (current !== MUSIC_ASK && !opts.some((o) => o.key === current)) {
1720
+ // chosen before the library list arrived: show it so the pick stays visible
1721
+ opts.push({ key: current, mood: this.picks.music_mood, file: this.picks.music_file, mark: '♪',
1722
+ label: this.picks.music_mood === MUSIC_FILE ? 'Usar arquivo da pasta' : this.picks.music_mood,
1723
+ desc: this.picks.music_mood === MUSIC_FILE ? baseName(this.picks.music_file) : 'Conferindo a biblioteca…' });
1724
+ }
1725
+ opts.forEach((opt) => {
1726
+ const card = el('button', {
1727
+ class: 'type-card music-card', type: 'button', role: 'radio', 'aria-checked': String(opt.key === current),
1728
+ 'data-id': opt.key, title: opt.title || '', 'aria-label': `${opt.label}. ${opt.desc}`,
1729
+ }, el('span', { class: 'music-mark', 'aria-hidden': 'true', text: opt.mark }),
1730
+ el('span', { class: 'tc-text' }, el('strong', { text: opt.label }), el('span', { class: 'tc-desc', text: opt.desc })));
1731
+ card.addEventListener('click', () => {
1732
+ this.picks.music_mood = opt.mood;
1733
+ this.picks.music_file = opt.file || '';
1734
+ this.picks.elements.music = opt.mood !== MUSIC_OFF;
1735
+ this.syncChecked(box, opt.key);
1736
+ this.touch();
1737
+ });
1738
+ box.append(card);
1739
+ });
1740
+ const t = this.trilhas;
1741
+ let help = 'Carregando a sua biblioteca de trilhas…';
1742
+ if (t && !t.ok) help = 'Não deu para ler a biblioteca de trilhas agora. Você pode pedir a trilha na conversa.';
1743
+ else if (t && t.error) help = `Problema na biblioteca de trilhas: ${t.error}`;
1744
+ else if (t && !(t.moods || []).length) help = 'Sua biblioteca de trilhas ainda está vazia. Peça ao agente, por exemplo: "guarda essa música como trilha animada".';
1745
+ else if (t) help = 'Cada clima usa a sua trilha favorita (★) ou a que encaixa melhor no tamanho do vídeo. A música fica baixinha por baixo da voz.';
1746
+ if (current === MUSIC_ASK) help = `Você pediu trilha. Escolha o clima ou o arquivo (ou deixe assim e o agente pergunta). ${help}`;
1747
+ this.els.musicHelp.textContent = help;
1748
+ }
1749
+
1750
+ renderDenoise() {
1751
+ const box = this.els.denoise;
1752
+ box.textContent = '';
1753
+ DENOISE.forEach((opt, level) => {
1754
+ // little meter: the voice bar stays, the noise bars around it shrink with the level
1755
+ const meter = el('span', { class: `noise-meter lv-${level}`, 'aria-hidden': 'true' },
1756
+ el('i', { class: 'nz' }), el('i', { class: 'nz' }), el('i', { class: 'vc' }), el('i', { class: 'vc tall' }),
1757
+ el('i', { class: 'vc' }), el('i', { class: 'nz' }), el('i', { class: 'nz' }));
1758
+ const card = el('button', {
1759
+ class: 'type-card noise-card', type: 'button', role: 'radio', 'aria-checked': String(this.picks.denoise === opt.id),
1760
+ 'data-id': opt.id, 'aria-label': `${opt.label}. ${opt.desc}`,
1761
+ }, meter, el('span', { class: 'tc-text' }, el('strong', { text: opt.label }), el('span', { class: 'tc-desc', text: opt.desc })));
1762
+ card.addEventListener('click', () => { this.picks.denoise = opt.id; this.syncChecked(box, opt.id); this.touch(); });
1763
+ box.append(card);
1764
+ });
1765
+ }
1766
+
1767
+ renderCtaState() {
1768
+ const hasCta = this.catalog && (this.catalog.elements || []).some((x) => x.id === 'cta');
1769
+ const off = hasCta && !this.picks.elements.cta;
1770
+ this.els.cta.disabled = off;
1771
+ this.els.ctaHelp.textContent = off ? 'O card de chamada está desligado em Elementos.' : 'Deixe vazio para o agente sugerir.';
1772
+ }
1773
+
1774
+ syncChecked(box, id) {
1775
+ box.querySelectorAll('[role="radio"]').forEach((c) => c.setAttribute('aria-checked', String(c.dataset.id === id)));
1776
+ }
1777
+
1778
+ setAccent(value, source) {
1779
+ const norm = normalizeHex(value);
1780
+ if (!norm || !this.picks) return;
1781
+ const changed = norm !== this.picks.accent;
1782
+ this.picks.accent = norm;
1783
+ if (source !== 'picker') this.els.picker.value = norm.toLowerCase();
1784
+ if (source !== 'hex') this.els.hex.value = norm;
1785
+ this.els.swatches.querySelectorAll('.swatch').forEach((sw) => sw.setAttribute('aria-pressed', String(normalizeHex(sw.title) === norm)));
1786
+ if (changed && source !== 'init') this.touch();
1787
+ this.renderPreview();
1788
+ this.renderSummary();
1789
+ }
1790
+
1791
+ hexError(on) {
1792
+ this.els.hex.classList.toggle('is-invalid', on);
1793
+ this.els.hexHelp.classList.toggle('is-error', on);
1794
+ this.els.hexHelp.textContent = on ? 'Use o formato #RRGGBB, por exemplo #FFC400.' : 'Formato #RRGGBB.';
1795
+ }
1796
+
1797
+ renderCounter() {
1798
+ const len = this.els.hookText.value.length;
1799
+ this.els.hookCount.textContent = len ? `${len}/90` : '';
1800
+ }
1801
+
1802
+ labelOf(list, id) {
1803
+ const item = (list || []).find((x) => x.id === id);
1804
+ return item ? item.label || item.id : id || 'nenhum';
1805
+ }
1806
+
1807
+ renderPreview() {
1808
+ if (!this.catalog || !this.picks) return;
1809
+ this.section.querySelectorAll('[data-pv]').forEach((b) => {
1810
+ const on = b.dataset.pv === this.pv;
1811
+ b.classList.toggle('on', on);
1812
+ b.setAttribute('aria-pressed', String(on));
1813
+ });
1814
+ const kind = this.hover ? this.hover.kind : this.pv;
1815
+ const id = this.hover ? this.hover.id : this.picks[`${kind}_style`];
1816
+ const url = this.sampleUrl(kind, id);
1817
+ const list = kind === 'hook' ? this.catalog.hook_styles : this.catalog.caption_styles;
1818
+ const { pvImg, pvPlaceholder, pvLabel, sampleNote } = this.els;
1819
+ if (url) {
1820
+ if (pvImg.getAttribute('src') !== url) pvImg.src = url;
1821
+ pvImg.hidden = false;
1822
+ pvPlaceholder.hidden = true;
1823
+ } else {
1824
+ pvImg.hidden = true;
1825
+ pvPlaceholder.hidden = false;
1826
+ pvLabel.textContent = this.labelOf(list, id);
1827
+ }
1828
+ const idx = this.data && this.data.samples;
1829
+ const sampleAccent = idx && normalizeHex(idx.accent);
1830
+ if (!idx) sampleNote.textContent = 'As amostras aparecem aqui quando o agente gerar.';
1831
+ else if (sampleAccent && sampleAccent !== this.picks.accent) sampleNote.textContent = `A amostra usa a cor ${sampleAccent}. No vídeo entra a cor que você escolher.`;
1832
+ else sampleNote.textContent = `${kind === 'hook' ? 'Gancho' : 'Legenda'} ${this.labelOf(list, id)} num quadro do seu vídeo.`;
1833
+ }
1834
+
1835
+ renderSummary() {
1836
+ if (!this.catalog || !this.picks) return;
1837
+ const box = this.els.summary;
1838
+ box.textContent = '';
1839
+ const shown = (this.catalog.elements || []).filter((x) => x.id !== 'music');
1840
+ const on = shown.filter((x) => this.picks.elements[x.id]).length;
1841
+ const total = shown.length;
1842
+ const rows = [
1843
+ ['Tipo', this.labelOf(this.catalog.edit_types, this.picks.edit_type) + (this.picks.edit_type === TV_ID ? ` · ${this.tvPosLabel().toLowerCase()}` : '')],
1844
+ ['Gancho', this.labelOf(this.catalog.hook_styles, this.picks.hook_style)],
1845
+ ['Legenda', this.labelOf(this.catalog.caption_styles, this.picks.caption_style)],
1846
+ ['Cor', [el('span', { class: 'chip', style: { background: this.picks.accent } }), this.picks.accent]],
1847
+ ['Elementos', `${on} de ${total} ligados`],
1848
+ ['Trilha', this.musicLabel()],
1849
+ ['Ruído de fundo', (DENOISE.find((d) => d.id === this.picks.denoise) || DENOISE[0]).label],
1850
+ ];
1851
+ rows.forEach(([k, v]) => box.append(el('dt', { text: k }), el('dd', {}, v)));
1852
+ }
1853
+
1854
+ renderSaved() {
1855
+ const st = this.els.saved;
1856
+ st.classList.remove('is-dirty', 'is-saved');
1857
+ if (this.dirty) { st.textContent = 'Alterações não salvas'; st.classList.add('is-dirty'); }
1858
+ else if (this.savedAt) { st.textContent = `Salvo às ${fmtClock(this.savedAt)}`; st.classList.add('is-saved'); }
1859
+ else st.textContent = 'Nada salvo ainda';
1860
+ }
1861
+
1862
+ async save() {
1863
+ if (this.saving || !this.picks) return;
1864
+ const hexNorm = normalizeHex(this.els.hex.value);
1865
+ if (!hexNorm) { this.hexError(true); this.els.hex.focus(); toast('A cor precisa estar no formato #RRGGBB.', 'error'); return; }
1866
+ this.setAccent(hexNorm, 'blur');
1867
+ this.saving = true;
1868
+ this.els.save.disabled = true;
1869
+ try {
1870
+ const body = {
1871
+ edit_type: this.picks.edit_type, caption_style: this.picks.caption_style, hook_style: this.picks.hook_style,
1872
+ accent: this.picks.accent, hook_text: String(this.picks.hook_text || '').trim(), cta_text: String(this.picks.cta_text || '').trim(),
1873
+ elements: this.picks.elements, denoise: this.picks.denoise, note: String(this.picks.note || '').trim(),
1874
+ music_mood: this.picks.music_mood || null,
1875
+ };
1876
+ if (this.picks.music_mood === MUSIC_FILE) body.music_file = this.picks.music_file;
1877
+ if (this.picks.edit_type === TV_ID) body.tela_verde_posicao = this.picks.tela_verde_posicao;
1878
+ if (this.picks.elements.motion) { body.motion_qtd = this.picks.motion_qtd; body.motion_tema = this.picks.motion_tema; }
1879
+ const res = await postJson('/api/style', body);
1880
+ this.dirty = false;
1881
+ this.savedAt = res.saved.saved_at;
1882
+ if (this.data && this.data.saved) this.data.saved.style = res.saved;
1883
+ this.baseKey = JSON.stringify(this.basePicks(this.data));
1884
+ store.del(this.draftKey);
1885
+ toast(handoff('Estilo salvo. O agente já recebeu suas escolhas.', 'Estilo salvo.'), 'ok', agentListening ? 3400 : 7000);
1886
+ } catch (err) {
1887
+ toast(err.message, 'error', 5000);
1888
+ } finally {
1889
+ this.saving = false;
1890
+ this.els.save.disabled = false;
1891
+ this.renderSaved();
1892
+ }
1893
+ }
1894
+ }
1895
+
1896
+ // ---------------------------------------------------------------- app shell
1897
+
1898
+ const App = {
1899
+ data: null,
1900
+ tab: null,
1901
+ lastPhase: undefined,
1902
+ lastMessage: null,
1903
+ connected: null,
1904
+ shownErrors: new Set(),
1905
+ trilhasAt: 0,
1906
+ trilhasBusy: false,
1907
+
1908
+ init() {
1909
+ this.views = {
1910
+ cut: new Review(document.getElementById('view-cut'), {
1911
+ phase: 'cut',
1912
+ approveLabel: 'Aprovar corte',
1913
+ approvedLabel: 'Corte aprovado',
1914
+ emptyTitle: 'O corte ainda está sendo feito…',
1915
+ emptyText: 'Assim que o agente terminar, o vídeo aparece aqui sozinho. Não precisa recarregar a página.',
1916
+ reloadedMsg: 'Novo corte carregado.',
1917
+ marksSavedMsg: 'Marcações enviadas. O agente vai ajustar o corte.',
1918
+ approvedMsg: 'Corte aprovado. O agente segue para o estilo.',
1919
+ approvedShort: 'Corte aprovado.',
1920
+ approvedBanner: (time) => `Você aprovou este corte às ${time}. O próximo passo é escolher o estilo.`,
1921
+ marksBanner: (time) => `Marcações enviadas às ${time}. O corte novo aparece aqui quando o agente terminar.`,
1922
+ }),
1923
+ style: new StyleView(document.getElementById('view-style')),
1924
+ final: new Review(document.getElementById('view-final'), {
1925
+ phase: 'final',
1926
+ approveLabel: 'Aprovar vídeo final',
1927
+ approvedLabel: 'Vídeo final aprovado',
1928
+ emptyTitle: 'O vídeo final ainda não foi montado',
1929
+ emptyText: 'Primeiro aprove o corte e escolha o estilo. Quando o agente terminar, o vídeo final aparece aqui sozinho.',
1930
+ reloadedMsg: 'Nova versão do vídeo final carregada.',
1931
+ marksSavedMsg: 'Marcações enviadas. O agente vai ajustar o vídeo.',
1932
+ approvedMsg: 'Vídeo final aprovado. Parabéns!',
1933
+ approvedShort: 'Vídeo final aprovado. Parabéns!',
1934
+ approvedBanner: (time, rel) => `Você aprovou o vídeo final às ${time}. O arquivo está em edit/${rel}.`,
1935
+ marksBanner: (time) => `Marcações enviadas às ${time}. A nova versão aparece aqui quando o agente terminar.`,
1936
+ }),
1937
+ };
1938
+ document.querySelectorAll('.tab').forEach((btn) => btn.addEventListener('click', () => this.setTab(btn.dataset.tab, true)));
1939
+ document.getElementById('help-btn').addEventListener('click', () => this.openHelp());
1940
+ document.addEventListener('keydown', (e) => this.onKey(e));
1941
+ document.addEventListener('keyup', (e) => {
1942
+ if ((e.code === 'Space' || e.key === ' ') && e.target && e.target.tagName === 'BUTTON' && this.activeReview()) e.preventDefault();
1943
+ });
1944
+ window.addEventListener('hashchange', () => { const t = HASH_TAB[location.hash]; if (t) this.setTab(t, true); });
1945
+ const fromHash = HASH_TAB[location.hash];
1946
+ this.setTab(fromHash || 'cut', Boolean(fromHash));
1947
+ this.poll();
1948
+ },
1949
+
1950
+ activeReview() {
1951
+ const view = this.views[this.tab];
1952
+ return view instanceof Review ? view : null;
1953
+ },
1954
+
1955
+ setTab(tab, byUser) {
1956
+ if (!this.views[tab]) return;
1957
+ const changed = tab !== this.tab;
1958
+ this.tab = tab;
1959
+ if (byUser) this.userPicked = true;
1960
+ document.querySelectorAll('.tab').forEach((btn) => btn.setAttribute('aria-selected', String(btn.dataset.tab === tab)));
1961
+ ['cut', 'style', 'final'].forEach((name) => {
1962
+ document.getElementById(`view-${name}`).hidden = name !== tab;
1963
+ const view = this.views[name];
1964
+ if (view instanceof Review) view.setActive(name === tab);
1965
+ });
1966
+ if (changed && location.hash !== TAB_HASH[tab]) history.replaceState(null, '', TAB_HASH[tab]);
1967
+ if (changed) window.scrollTo(0, 0);
1968
+ },
1969
+
1970
+ openHelp() {
1971
+ const dialog = document.getElementById('help-dialog');
1972
+ if (!dialog.open) dialog.showModal();
1973
+ },
1974
+
1975
+ onKey(e) {
1976
+ if (e.defaultPrevented || document.querySelector('dialog[open]')) return;
1977
+ const typing = isTypingTarget(e.target);
1978
+ if (e.key === '?' && !typing) { e.preventDefault(); this.openHelp(); return; }
1979
+ if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
1980
+ const review = this.activeReview();
1981
+ if (review && review.handleKey(e)) e.preventDefault();
1982
+ },
1983
+
1984
+ async poll() {
1985
+ try {
1986
+ const res = await fetch('/api/state', { cache: 'no-store' });
1987
+ if (!res.ok) throw new Error(String(res.status));
1988
+ const data = await res.json();
1989
+ this.setConnected(true);
1990
+ this.apply(data);
1991
+ } catch {
1992
+ this.setConnected(false);
1993
+ } finally {
1994
+ setTimeout(() => this.poll(), POLL_MS);
1995
+ }
1996
+ },
1997
+
1998
+ setConnected(ok) {
1999
+ if (ok === this.connected) return;
2000
+ const was = this.connected;
2001
+ this.connected = ok;
2002
+ const line = document.getElementById('statusline');
2003
+ line.classList.toggle('is-offline', !ok);
2004
+ if (!ok) {
2005
+ document.getElementById('status-text').textContent = 'Sem conexão com o preview. Ele ainda está rodando no terminal? Tentando de novo…';
2006
+ document.getElementById('status-badge').hidden = true;
2007
+ document.getElementById('status-meta').textContent = '';
2008
+ } else if (was === false) {
2009
+ this.lastMessage = null;
2010
+ toast('Conexão com o preview de volta.');
2011
+ }
2012
+ },
2013
+
2014
+ apply(data) {
2015
+ this.data = data;
2016
+ if (data.token) previewToken = data.token;
2017
+ agentListening = Boolean(data.watcher && data.watcher.alive);
2018
+ const state = data.state || null;
2019
+ const phase = state && state.phase ? state.phase : 'setup';
2020
+
2021
+ // tab follows the phase when the agent moves on
2022
+ if (this.lastPhase === undefined) {
2023
+ if (!this.userPicked) this.setTab(PHASE_TAB[phase] || 'cut', false);
2024
+ } else if (phase !== this.lastPhase && PHASE_TAB[phase] && PHASE_TAB[phase] !== this.tab) {
2025
+ this.setTab(PHASE_TAB[phase], false);
2026
+ toast(`Etapa agora: ${PHASE_LABEL[phase] || phase}.`);
2027
+ }
2028
+ this.lastPhase = phase;
2029
+
2030
+ this.views.cut.update(data);
2031
+ this.views.style.update(data);
2032
+ this.views.final.update(data);
2033
+ this.loadTrilhas();
2034
+ this.renderStatus(state, phase);
2035
+
2036
+ Object.entries(data.errors || {}).forEach(([file, msg]) => {
2037
+ const key = `${file}:${msg}`;
2038
+ if (!this.shownErrors.has(key)) { this.shownErrors.add(key); toast(`Problema em ${file}: ${msg}`, 'error', 6000); }
2039
+ });
2040
+ },
2041
+
2042
+ // moods of the personal soundtrack library (read-only route, needs the preview token)
2043
+ async loadTrilhas(force = false) {
2044
+ if (this.trilhasBusy || (!force && Date.now() - this.trilhasAt < TRILHAS_MS) || !previewToken) return;
2045
+ this.trilhasBusy = true;
2046
+ try {
2047
+ const res = await fetch('/api/trilhas', { cache: 'no-store', headers: { 'X-Preview-Token': previewToken } });
2048
+ const body = await res.json().catch(() => ({}));
2049
+ if (res.ok && body.ok) {
2050
+ this.trilhasAt = Date.now();
2051
+ this.views.style.setTrilhas(body);
2052
+ } else {
2053
+ this.trilhasAt = Date.now() - TRILHAS_MS + 2000; // try again soon (the server may have restarted)
2054
+ if (!this.views.style.trilhas) this.views.style.setTrilhas({ ok: false });
2055
+ }
2056
+ } catch {
2057
+ this.trilhasAt = Date.now() - TRILHAS_MS + 2000;
2058
+ } finally {
2059
+ this.trilhasBusy = false;
2060
+ }
2061
+ },
2062
+
2063
+ // saved_at of the approval, marks or style the person sent for what the agent is waiting on, or null
2064
+ receivedAt(state, awaiting) {
2065
+ const saved = (this.data && this.data.saved) || {};
2066
+ const since = parseMs(state && state.updated_at);
2067
+ if (awaiting === 'approval_cut' || awaiting === 'approval_final') {
2068
+ const phase = awaiting === 'approval_cut' ? 'cut' : 'final';
2069
+ const view = this.views[phase];
2070
+ const ap = saved.approval;
2071
+ if (view && view.isApproved() && parseMs(ap.saved_at) >= since) return ap.saved_at;
2072
+ const mk = saved.marks;
2073
+ if (view && view.info && mk && mk.phase === phase && Array.isArray(mk.marks) && mk.marks.length
2074
+ && parseMs(mk.saved_at) >= view.info.mtime * 1000 - 1 && parseMs(mk.saved_at) >= since) return mk.saved_at;
2075
+ } else if (awaiting === 'style' && saved.style && parseMs(saved.style.saved_at) >= since) {
2076
+ return saved.style.saved_at;
2077
+ }
2078
+ return null;
2079
+ },
2080
+
2081
+ renderStatus(state, phase) {
2082
+ const project = document.getElementById('project-name');
2083
+ const name = state && state.project ? state.project : '';
2084
+ if (project.textContent !== name) { project.textContent = name; document.title = name ? `${name} · Preview da edição` : 'Preview da edição'; }
2085
+
2086
+ let awaiting = state ? state.awaiting : null;
2087
+ const received = this.receivedAt(state, awaiting);
2088
+ if (received) awaiting = null; // the person already acted: it is the agent's turn now
2089
+ document.querySelectorAll('.tab').forEach((btn) => btn.classList.toggle('is-awaiting', AWAIT_TAB[awaiting] === btn.dataset.tab));
2090
+
2091
+ const line = document.getElementById('statusline');
2092
+ const text = document.getElementById('status-text');
2093
+ const badge = document.getElementById('status-badge');
2094
+ const meta = document.getElementById('status-meta');
2095
+ let message = state && state.message;
2096
+ if (received) {
2097
+ message = agentListening ? 'Recebido. O agente está trabalhando nisso.' : `Recebido. ${NUDGE}`;
2098
+ }
2099
+ if (!message) {
2100
+ message = state ? {
2101
+ setup: 'O agente está preparando a edição.',
2102
+ cut: 'O agente está trabalhando no corte.',
2103
+ style: 'Escolha o estilo do vídeo e salve.',
2104
+ final: 'O agente está montando o vídeo final.',
2105
+ }[phase] || 'O agente está trabalhando.' : 'Esperando o agente começar a edição.';
2106
+ }
2107
+ if (message !== this.lastMessage) {
2108
+ text.textContent = message;
2109
+ if (this.lastMessage !== null) { text.classList.remove('flash'); void text.offsetWidth; text.classList.add('flash'); }
2110
+ this.lastMessage = message;
2111
+ }
2112
+ badge.hidden = !awaiting;
2113
+ line.classList.toggle('is-awaiting', Boolean(awaiting));
2114
+ line.classList.toggle('is-working', !awaiting);
2115
+ const parts = [`Etapa: ${PHASE_LABEL[phase] || phase}`];
2116
+ if (state && state.updated_at) { const clock = fmtClock(state.updated_at); if (clock) parts.push(`atualizado às ${clock}`); }
2117
+ meta.textContent = parts.join(' · ');
2118
+ },
2119
+ };
2120
+
2121
+ document.addEventListener('DOMContentLoaded', () => App.init());
2122
+ })();