primocode 10.16.0 → 10.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSES/edit-ia-pro.txt +32 -0
  2. package/LICENSES/fontes-OFL.txt +476 -0
  3. package/LICENSES/hyperframes-Apache-2.0.txt +205 -0
  4. package/LICENSES/permanent-marker-Apache-2.0.txt +204 -0
  5. package/LICENSES/selfie-multiclass-Apache-2.0.txt +227 -0
  6. package/LICENSES/three-MIT.txt +23 -0
  7. package/LICENSES/video-use-MIT.txt +24 -0
  8. package/LICENSES/woven-sfx-CC0.txt +16 -0
  9. package/LICENSES/yunet-MIT.txt +21 -0
  10. package/README.md +2 -2
  11. package/bin/primocode.js +11 -2
  12. package/editor/LICENSE.md +11 -0
  13. package/editor/README.md +321 -0
  14. package/editor/SKILL.md +307 -0
  15. package/editor/THIRD_PARTY_NOTICES.md +228 -0
  16. package/editor/assets/baixar.json +591 -0
  17. package/editor/assets/fonts/anton/OFL.txt +93 -0
  18. package/editor/assets/fonts/caveat/OFL.txt +93 -0
  19. package/editor/assets/fonts/figtree/OFL.txt +93 -0
  20. package/editor/assets/fonts/fonts.json +10 -0
  21. package/editor/assets/fonts/montserrat/OFL.txt +93 -0
  22. package/editor/assets/fonts/permanentmarker/LICENSE.txt +202 -0
  23. package/editor/assets/models/SELFIE_MULTICLASS_LICENSE.txt +227 -0
  24. package/editor/assets/models/YUNET_LICENSE.txt +21 -0
  25. package/editor/assets/sfx/licencas/bass_drop.txt +32 -0
  26. package/editor/assets/sfx/licencas/cash.txt +76 -0
  27. package/editor/assets/sfx/licencas/charge.txt +31 -0
  28. package/editor/assets/sfx/licencas/click.txt +46 -0
  29. package/editor/assets/sfx/licencas/coin.txt +50 -0
  30. package/editor/assets/sfx/licencas/count.txt +47 -0
  31. package/editor/assets/sfx/licencas/data.txt +31 -0
  32. package/editor/assets/sfx/licencas/error.txt +32 -0
  33. package/editor/assets/sfx/licencas/flash.txt +31 -0
  34. package/editor/assets/sfx/licencas/gears.txt +82 -0
  35. package/editor/assets/sfx/licencas/keyboard.txt +48 -0
  36. package/editor/assets/sfx/licencas/metal_hit.txt +62 -0
  37. package/editor/assets/sfx/licencas/metal_riser.txt +61 -0
  38. package/editor/assets/sfx/licencas/mouse_click.txt +50 -0
  39. package/editor/assets/sfx/licencas/notification.txt +54 -0
  40. package/editor/assets/sfx/licencas/pop.txt +38 -0
  41. package/editor/assets/sfx/licencas/rewind.txt +32 -0
  42. package/editor/assets/sfx/licencas/rise.txt +46 -0
  43. package/editor/assets/sfx/licencas/sinteticos.txt +21 -0
  44. package/editor/assets/sfx/licencas/snap.txt +50 -0
  45. package/editor/assets/sfx/licencas/success.txt +40 -0
  46. package/editor/assets/sfx/licencas/synth_hit.txt +32 -0
  47. package/editor/assets/sfx/licencas/tick.txt +14 -0
  48. package/editor/assets/sfx/licencas/whoosh.txt +47 -0
  49. package/editor/assets/sfx/licencas/wind.txt +31 -0
  50. package/editor/assets/sfx/sfx.json +832 -0
  51. package/editor/assets/sfx/sfx_sinteticos.json +317 -0
  52. package/editor/assets/sfx/sfx_woven.json +387 -0
  53. package/editor/assets/sfx/woven/LICENCA.md +16 -0
  54. package/editor/fichas/briefing-do-video.md +45 -0
  55. package/editor/fichas/conferencia-da-amostra.md +56 -0
  56. package/editor/fichas/matriz-de-ganchos.csv +3 -0
  57. package/editor/fichas/padrao.json +16 -0
  58. package/editor/fichas/planilha-de-cenas.csv +4 -0
  59. package/editor/fichas/planilha-de-versoes.csv +4 -0
  60. package/editor/fichas/plano-em-blocos.md +32 -0
  61. package/editor/fichas/registro-de-materiais.json +27 -0
  62. package/editor/helpers/assets_ocr/ocr_vision.swift +41 -0
  63. package/editor/helpers/bordas_precisas.py +272 -0
  64. package/editor/helpers/camera_viva.py +426 -0
  65. package/editor/helpers/captions.py +601 -0
  66. package/editor/helpers/common.py +308 -0
  67. package/editor/helpers/compose_final.py +3555 -0
  68. package/editor/helpers/conferir_corte.py +234 -0
  69. package/editor/helpers/conferir_dados.py +405 -0
  70. package/editor/helpers/contact_sheet.py +282 -0
  71. package/editor/helpers/corte_por_onda.py +433 -0
  72. package/editor/helpers/corte_seco.py +595 -0
  73. package/editor/helpers/denoise.py +971 -0
  74. package/editor/helpers/efeitos.py +408 -0
  75. package/editor/helpers/efeitos_gerar.py +159 -0
  76. package/editor/helpers/elevenlabs_api.py +442 -0
  77. package/editor/helpers/face_layout.py +570 -0
  78. package/editor/helpers/fetch_images.py +423 -0
  79. package/editor/helpers/ganho_por_trecho.py +214 -0
  80. package/editor/helpers/gen_image.py +312 -0
  81. package/editor/helpers/gen_music.py +316 -0
  82. package/editor/helpers/gen_sfx.py +148 -0
  83. package/editor/helpers/grade.py +307 -0
  84. package/editor/helpers/icloud.py +119 -0
  85. package/editor/helpers/make_sfx.py +940 -0
  86. package/editor/helpers/matte.py +1342 -0
  87. package/editor/helpers/medir_referencia.py +2232 -0
  88. package/editor/helpers/mixar_trilha.py +354 -0
  89. package/editor/helpers/montar_corte.py +295 -0
  90. package/editor/helpers/motion/__init__.py +13 -0
  91. package/editor/helpers/motion/engine.py +1915 -0
  92. package/editor/helpers/motion/registry.py +510 -0
  93. package/editor/helpers/motion/render.py +665 -0
  94. package/editor/helpers/motion/templates/barra_progresso.py +370 -0
  95. package/editor/helpers/motion/templates/carimbo.py +132 -0
  96. package/editor/helpers/motion/templates/checklist.py +271 -0
  97. package/editor/helpers/motion/templates/comentario_cta.py +380 -0
  98. package/editor/helpers/motion/templates/contador.py +222 -0
  99. package/editor/helpers/motion/templates/destaque.py +263 -0
  100. package/editor/helpers/motion/templates/etiqueta_preco.py +461 -0
  101. package/editor/helpers/motion/templates/grafico_barras.py +510 -0
  102. package/editor/helpers/motion/templates/grafico_linha.py +462 -0
  103. package/editor/helpers/motion/templates/notificacao.py +266 -0
  104. package/editor/helpers/motion/templates/palavra_impacto.py +410 -0
  105. package/editor/helpers/motion/templates/passos.py +321 -0
  106. package/editor/helpers/motion/templates/riscado.py +298 -0
  107. package/editor/helpers/motion/templates/selo_garantia.py +150 -0
  108. package/editor/helpers/motion_render.py +241 -0
  109. package/editor/helpers/motion_sugerir.py +961 -0
  110. package/editor/helpers/noise_check.py +134 -0
  111. package/editor/helpers/pack_transcripts.py +223 -0
  112. package/editor/helpers/palavras_na_onda.py +176 -0
  113. package/editor/helpers/plano_sfx.py +839 -0
  114. package/editor/helpers/portao.py +427 -0
  115. package/editor/helpers/preview_server.py +1177 -0
  116. package/editor/helpers/rastrear_rosto.py +298 -0
  117. package/editor/helpers/render_cut.py +1256 -0
  118. package/editor/helpers/respiros.py +89 -0
  119. package/editor/helpers/sincronizar_cameras.py +571 -0
  120. package/editor/helpers/speech_regions.py +423 -0
  121. package/editor/helpers/style_catalog.json +231 -0
  122. package/editor/helpers/style_samples.py +246 -0
  123. package/editor/helpers/tela_verde.py +2113 -0
  124. package/editor/helpers/text_render.py +2059 -0
  125. package/editor/helpers/texto_na_tela.py +366 -0
  126. package/editor/helpers/timeline_view.py +292 -0
  127. package/editor/helpers/tira_de_quadros.py +113 -0
  128. package/editor/helpers/transcribe.py +734 -0
  129. package/editor/helpers/transcribe_batch.py +129 -0
  130. package/editor/helpers/trilhas.py +1146 -0
  131. package/editor/helpers/trilhas_humores.json +66 -0
  132. package/editor/helpers/valores.py +187 -0
  133. package/editor/helpers/verify_cut.py +820 -0
  134. package/editor/helpers/voice_levels.py +233 -0
  135. package/editor/helpers/voz_check.py +386 -0
  136. package/editor/helpers/voz_limpa.py +81 -0
  137. package/editor/helpers/watch_preview.py +372 -0
  138. package/editor/modelo-projeto/LEIA.md +402 -0
  139. package/editor/modelo-projeto/assets/fontes/OFL-Caveat.txt +93 -0
  140. package/editor/modelo-projeto/assets/fontes/OFL-JetBrainsMono.txt +93 -0
  141. package/editor/modelo-projeto/assets/fontes/OFL-Montserrat.txt +93 -0
  142. package/editor/modelo-projeto/assets/fontes/larguras.json +1 -0
  143. package/editor/modelo-projeto/assets/three/BufferGeometryUtils.js +1438 -0
  144. package/editor/modelo-projeto/assets/three/GLTFLoader.js +4895 -0
  145. package/editor/modelo-projeto/assets/three/LICENSE +21 -0
  146. package/editor/modelo-projeto/assets/three/ORIGEM.txt +17 -0
  147. package/editor/modelo-projeto/assets/three/RoomEnvironment.js +185 -0
  148. package/editor/modelo-projeto/assets/three/three.core.min.js +6 -0
  149. package/editor/modelo-projeto/assets/three/three.module.min.js +6 -0
  150. package/editor/modelo-projeto/build.py +4828 -0
  151. package/editor/modelo-projeto/compositions/cenas/2d/k2d_antes_depois.html +199 -0
  152. package/editor/modelo-projeto/compositions/cenas/2d/k2d_arquivo.html +232 -0
  153. package/editor/modelo-projeto/compositions/cenas/2d/k2d_busca.html +315 -0
  154. package/editor/modelo-projeto/compositions/cenas/2d/k2d_comando.html +304 -0
  155. package/editor/modelo-projeto/compositions/cenas/2d/k2d_conversa.html +217 -0
  156. package/editor/modelo-projeto/compositions/cenas/2d/k2d_esquema.html +289 -0
  157. package/editor/modelo-projeto/compositions/cenas/2d/k2d_medidor.html +249 -0
  158. package/editor/modelo-projeto/compositions/cenas/2d/k2d_notas.html +233 -0
  159. package/editor/modelo-projeto/compositions/cenas/3d/k3d_caminho.html +197 -0
  160. package/editor/modelo-projeto/compositions/cenas/3d/k3d_explodida.html +152 -0
  161. package/editor/modelo-projeto/compositions/cenas/3d/k3d_tela.html +167 -0
  162. package/editor/modelo-projeto/compositions/cenas/3d/k3d_volume.html +124 -0
  163. package/editor/modelo-projeto/compositions/cenas/3d/motor-three.js +728 -0
  164. package/editor/modelo-projeto/compositions/cenas/LEIA.md +177 -0
  165. package/editor/modelo-projeto/ferramentas/medir_fontes.py +65 -0
  166. package/editor/modelo-projeto/ferramentas/pessoa_recortada.py +176 -0
  167. package/editor/modelo-projeto/ferramentas/rastrear_maos.py +189 -0
  168. package/editor/modelo-projeto/hyperframes.json +12 -0
  169. package/editor/modelo-projeto/package.json +11 -0
  170. package/editor/modelo-projeto/projeto.json +16 -0
  171. package/editor/preview/app.css +1009 -0
  172. package/editor/preview/app.js +2122 -0
  173. package/editor/preview/index.html +307 -0
  174. package/editor/pyproject.toml +23 -0
  175. package/editor/references/3d.md +236 -0
  176. package/editor/references/animacao-catalogo.md +672 -0
  177. package/editor/references/animacao.md +498 -0
  178. package/editor/references/comandos.md +92 -0
  179. package/editor/references/corte-seco-como-funciona.md +50 -0
  180. package/editor/references/corte-seco.md +88 -0
  181. package/editor/references/corte.md +290 -0
  182. package/editor/references/direcao-por-blocos.md +218 -0
  183. package/editor/references/enquadramento.md +654 -0
  184. package/editor/references/estilos-prontos.md +355 -0
  185. package/editor/references/estudio.md +100 -0
  186. package/editor/references/exemplo-eventos.json +67 -0
  187. package/editor/references/lettering.md +252 -0
  188. package/editor/references/modelos.md +494 -0
  189. package/editor/references/phase2.md +1007 -0
  190. package/editor/references/referencia-aplicar.md +203 -0
  191. package/editor/references/referencia-medir.md +375 -0
  192. package/editor/references/revisao.md +335 -0
  193. package/editor/references/som-e-trilha.md +146 -0
  194. package/editor/references/tabela-de-sons.md +130 -0
  195. package/editor/references/variacoes.md +234 -0
  196. package/editor/uv.lock +1141 -0
  197. package/lib/act.js +36 -8
  198. package/lib/catalogo.js +4 -0
  199. package/lib/chamada-torta.js +81 -0
  200. package/lib/editor-pro.js +786 -0
  201. package/lib/entrega.js +58 -0
  202. package/lib/estudio/conferencia-visual.js +76 -0
  203. package/lib/estudio/diretor.js +6 -1
  204. package/lib/estudio/exportar.js +3 -1
  205. package/lib/estudio/gosto.js +45 -4
  206. package/lib/estudio/index.js +8 -5
  207. package/lib/estudio/kit-ae/base.jsx +119 -13
  208. package/lib/estudio/kit-ae/cenas.jsx +47 -15
  209. package/lib/estudio/kit-ae/guarda.jsx +274 -0
  210. package/lib/estudio/kit-ae/primitivas.jsx +180 -24
  211. package/lib/estudio/maquina-diretor.js +61 -7
  212. package/lib/estudio/maquina-foto.js +177 -0
  213. package/lib/estudio/maquina-guarda.js +207 -0
  214. package/lib/estudio/maquina-layout.js +22 -1
  215. package/lib/estudio/maquina.js +60 -11
  216. package/lib/estudio/referencias.js +1 -1
  217. package/lib/estudio/visao/visao.swift +97 -0
  218. package/lib/estudio/visao.js +60 -0
  219. package/lib/local/cerebro.json +192 -6
  220. package/lib/local/motor.js +5 -2
  221. package/lib/nuvem.js +11 -1
  222. package/lib/passagem.js +50 -0
  223. package/lib/perigo.js +19 -11
  224. package/lib/protecao.js +134 -0
  225. package/lib/titulo-conversa.js +46 -0
  226. package/lib/tools.js +3 -0
  227. package/lib/ui.js +1 -1
  228. package/package.json +3 -2
  229. package/skills/editar-meu-video/SKILL.md +6 -2
  230. package/skills/editar-video/SKILL.md +113 -0
  231. package/studio/pessoa.py +52 -0
  232. package/voz/primo_voz/app.py +43 -4
  233. package/voz/primo_voz/chat.html +17 -6
  234. package/voz/primo_voz/painel_dados.py +134 -0
@@ -0,0 +1,1177 @@
1
+ """Local web preview + JSON API for one edit session (SPEC Contract 9).
2
+
3
+ Serves the preview UI (preview/index.html, app.js, app.css), the media inside the
4
+ edit dir with HTTP Range support (so the player can scrub), cached waveform and
5
+ filmstrip PNGs, and a small JSON API the UI uses to send marks, approvals and
6
+ style picks back to the agent.
7
+
8
+ Stdlib HTTP server. numpy + PIL + ffmpeg are only used to draw the strips.
9
+
10
+ Security (the agent treats every saved click as the person speaking):
11
+ - binds to 127.0.0.1 by default; the Host header must name this computer (blocks DNS rebinding);
12
+ - /api/* and /media/* refuse requests another site started (Sec-Fetch-Site cross-site/same-site);
13
+ - every POST needs the per-run token from /api/state in an X-Preview-Token header, a matching Origin
14
+ when one is sent, and JSON; the body is always read and the connection closed, so bytes of a
15
+ refused request can never run as a second request;
16
+ - with --host other than loopback, every route needs the token (link with ?t=..., then a cookie);
17
+ - GET /api/trilhas (the moods of the personal soundtrack library, read-only) also needs the token header
18
+ and a matching Origin when one is sent.
19
+
20
+ It writes preview_cache/server.json ({status, url, port, host, pid, edit_dir, started_at}, or
21
+ {status: "error", error}) so the agent never has to parse the log.
22
+
23
+ Usage:
24
+ uv run python helpers/preview_server.py --edit-dir DIR [--port 4830] [--host 127.0.0.1] [--open]
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import errno
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+ import mimetypes
35
+ import os
36
+ import re
37
+ import secrets
38
+ import signal
39
+ import socket
40
+ import subprocess
41
+ import sys
42
+ import threading
43
+ import time
44
+ import urllib.error
45
+ import urllib.parse
46
+ import urllib.request
47
+ import webbrowser
48
+ from datetime import datetime, timezone
49
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
54
+
55
+ from common import ( # noqa: E402
56
+ PREVIEW_DIR,
57
+ STYLE_CATALOG_PATH,
58
+ file_signature,
59
+ load_json,
60
+ probe_video,
61
+ save_json,
62
+ )
63
+ import trilhas # noqa: E402 (personal soundtrack library, read-only here)
64
+
65
+ DEFAULT_PORT = 4830
66
+ MAX_BODY = 1_000_000
67
+ CHUNK = 256 * 1024
68
+ LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
69
+ LOOPBACK_NAMES = {"127.0.0.1", "localhost", "[::1]", "::1"}
70
+ TOKEN_HEADER = "X-Preview-Token"
71
+ TOKEN_COOKIE = "ev_preview"
72
+ WATCHER_ALIVE_S = 10.0
73
+ STRIP_JOBS = threading.BoundedSemaphore(2) # at most 2 ffmpeg strip renders at once
74
+ STRIP_VARIANTS_KEPT = 6 # newest cached PNGs kept per kind and file
75
+ STATIC_FILES = {
76
+ "/": ("index.html", "text/html; charset=utf-8"),
77
+ "/index.html": ("index.html", "text/html; charset=utf-8"),
78
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
79
+ "/app.css": ("app.css", "text/css; charset=utf-8"),
80
+ }
81
+ CSP = (
82
+ "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; "
83
+ "style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; "
84
+ "font-src 'self' data:; frame-ancestors 'self'"
85
+ )
86
+ MEDIA_TYPES = {
87
+ ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
88
+ ".mkv": "video/x-matroska", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
89
+ ".aac": "audio/aac", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
90
+ ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml",
91
+ ".json": "application/json; charset=utf-8", ".md": "text/markdown; charset=utf-8",
92
+ ".txt": "text/plain; charset=utf-8",
93
+ }
94
+ PRIVACY_HINT = (
95
+ "No macOS, pastas como Documentos, Mesa e Downloads (e discos externos) só aceitam gravação "
96
+ "de apps autorizados. Abra Ajustes do Sistema > Privacidade e Segurança > Acesso Total ao "
97
+ "Disco e ative o app que roda o agente (Terminal, iTerm, Claude, Codex ou o editor). Outra "
98
+ "saída: mova os vídeos para uma pasta fora dessas, por exemplo ~/Videos/meu-criativo."
99
+ )
100
+
101
+
102
+ def utf8_console() -> None:
103
+ """Windows pipes default to the ANSI code page: an emoji in a path or note must not crash the server."""
104
+ for stream in (sys.stdout, sys.stderr):
105
+ try:
106
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
107
+ except (AttributeError, ValueError, OSError):
108
+ pass
109
+
110
+
111
+ def log(msg: str) -> None:
112
+ print(msg, flush=True)
113
+
114
+
115
+ def now_iso_ms() -> str:
116
+ """ISO time with milliseconds, so two saves in the same second stay distinct for the watcher."""
117
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="milliseconds")
118
+
119
+
120
+ # ------------------------------------------------------------------ session
121
+
122
+
123
+ class Session:
124
+ """Everything the handlers need about one edit dir."""
125
+
126
+ def __init__(self, edit_dir: Path, catalog_path: Path, host: str, port: int) -> None:
127
+ self.edit_dir = edit_dir.resolve()
128
+ self.catalog_path = catalog_path
129
+ self.cache_dir = self.edit_dir / "preview_cache"
130
+ self.host = host
131
+ self.port = port
132
+ self.token = secrets.token_urlsafe(24)
133
+ self.loopback = host in LOOPBACK_HOSTS
134
+ self.allowed_names = set(LOOPBACK_NAMES) if self.loopback else allowed_host_names(host)
135
+ self._locks: dict[str, threading.Lock] = {}
136
+ self._locks_guard = threading.Lock()
137
+ self._last_good: dict[str, Any] = {}
138
+
139
+ # -- paths
140
+
141
+ def safe_path(self, rel: str) -> Path | None:
142
+ """Resolve a path given relative to the edit dir. None when it escapes the edit dir."""
143
+ if rel is None:
144
+ return None
145
+ rel = str(rel).replace("\\", "/")
146
+ if "\x00" in rel:
147
+ return None
148
+ rel = rel.lstrip("/")
149
+ if not rel:
150
+ return None
151
+ try:
152
+ candidate = (self.edit_dir / rel).resolve()
153
+ except (OSError, RuntimeError):
154
+ return None
155
+ if candidate != self.edit_dir and self.edit_dir not in candidate.parents:
156
+ return None
157
+ return candidate
158
+
159
+ def lock_for(self, key: str) -> threading.Lock:
160
+ with self._locks_guard:
161
+ lock = self._locks.get(key)
162
+ if lock is None:
163
+ lock = self._locks[key] = threading.Lock()
164
+ return lock
165
+
166
+ # -- state
167
+
168
+ def read_json_file(self, rel: str | None, errors: dict[str, str]) -> Any:
169
+ if not rel:
170
+ return None
171
+ path = self.safe_path(rel)
172
+ if path is None or not path.is_file():
173
+ return None
174
+ key = str(path)
175
+ for attempt in range(3):
176
+ try:
177
+ data = load_json(path)
178
+ self._last_good[key] = data
179
+ return data
180
+ except json.JSONDecodeError as exc:
181
+ if attempt == 2:
182
+ errors[rel] = f"JSON inválido: {exc}"
183
+ return self._last_good.get(key)
184
+ time.sleep(0.05)
185
+ except OSError as exc:
186
+ errors[rel] = f"não consegui ler: {exc}"
187
+ return self._last_good.get(key)
188
+ return None
189
+
190
+ def file_info(self, rel: str) -> dict | None:
191
+ path = self.safe_path(rel)
192
+ if path is None or not path.is_file():
193
+ return None
194
+ st = path.stat()
195
+ return {"mtime": st.st_mtime, "size": st.st_size, "age": max(0.0, time.time() - st.st_mtime)}
196
+
197
+ def api_state(self) -> dict:
198
+ errors: dict[str, str] = {}
199
+ state = self.read_json_file("state.json", errors)
200
+ if not isinstance(state, dict):
201
+ state = None if state is None else state
202
+ st = state if isinstance(state, dict) else {}
203
+
204
+ def rel_of(key: str, default: str | None) -> str | None:
205
+ value = st.get(key)
206
+ if isinstance(value, str) and value.strip():
207
+ return value.strip()
208
+ return default
209
+
210
+ timeline_rel = rel_of("cut_timeline", "cut_timeline.json")
211
+ captions_rel = rel_of("captions", "captions.json")
212
+ edit_data_rel = rel_of("edit_data", "edit_data.json")
213
+ samples_rel = rel_of("samples", "samples/index.json")
214
+ cut_rel = rel_of("cut_video", "cut.mp4")
215
+ final_rel = rel_of("final_video", None)
216
+
217
+ files: dict[str, Any] = {}
218
+ for rel in dict.fromkeys(["cut.mp4", "final.mp4", cut_rel, final_rel, timeline_rel,
219
+ captions_rel, edit_data_rel, samples_rel]):
220
+ if rel:
221
+ files[rel] = self.file_info(rel)
222
+
223
+ catalog = None
224
+ try:
225
+ catalog = load_json(self.catalog_path)
226
+ except (json.JSONDecodeError, OSError) as exc:
227
+ errors["style_catalog.json"] = f"catálogo de estilos inválido: {exc}"
228
+
229
+ saved = {
230
+ "marks": self.read_json_file("preview_marks.json", errors),
231
+ "trims": self.read_json_file("preview_trims.json", errors),
232
+ "approval": self.read_json_file("preview_approval.json", errors),
233
+ "style": self.read_json_file("preview_style.json", errors),
234
+ }
235
+ watcher = {"alive": False, "age": None}
236
+ try:
237
+ beat = load_json(self.cache_dir / "watcher.json") or {}
238
+ if beat.get("updated_at"):
239
+ age = max(0.0, time.time() - float(beat["updated_at"]))
240
+ watcher = {"alive": bool(beat.get("mode") == "loop" and age < WATCHER_ALIVE_S), "age": round(age, 1)}
241
+ except (json.JSONDecodeError, OSError, TypeError, ValueError):
242
+ pass
243
+ return {
244
+ "state": state,
245
+ "timeline": self.read_json_file(timeline_rel, errors),
246
+ "captions": self.read_json_file(captions_rel, errors),
247
+ "edit_data": self.read_json_file(edit_data_rel, errors),
248
+ "samples": self.read_json_file(samples_rel, errors),
249
+ "catalog": catalog,
250
+ "files": files,
251
+ "saved": saved,
252
+ "edit_dir": str(self.edit_dir),
253
+ "errors": errors,
254
+ "server_time": time.time(),
255
+ "token": self.token,
256
+ "watcher": watcher,
257
+ }
258
+
259
+ def api_trilhas(self) -> dict:
260
+ """Moods of the personal library that have tracks, plus the music files already in this edit.
261
+
262
+ Read-only: never creates the library or changes the index."""
263
+ summary = trilhas.moods_summary()
264
+ try:
265
+ files = trilhas.edit_music_files(self.edit_dir)
266
+ except OSError:
267
+ files = []
268
+ errors = {}
269
+ edit_data = self.read_json_file("edit_data.json", errors)
270
+ music = edit_data.get("music") if isinstance(edit_data, dict) else None
271
+ current = music.get("file") if isinstance(music, dict) else None
272
+ if isinstance(current, str) and current.strip() and current not in files:
273
+ path = self.safe_path(current)
274
+ if path is not None and path.is_file():
275
+ files.insert(0, current.strip())
276
+ out = {"ok": True, "exists": bool(summary.get("exists")), "moods": summary.get("moods") or [],
277
+ "edit_files": files[:8]}
278
+ if summary.get("error"):
279
+ out["error"] = summary["error"]
280
+ return out
281
+
282
+ # -- strips
283
+
284
+ def _cache_png(self, kind: str, rel: str, path: Path, params: str, build) -> Path:
285
+ """PNG cached as preview_cache/<kind>_<path hash>_<file sig hash>_<params>.png.
286
+
287
+ When the file changes (new sig), strips of older versions of that file are removed.
288
+ """
289
+ sig = file_signature(path)
290
+ rel_hash = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:10]
291
+ sig_hash = hashlib.sha1(sig.encode("utf-8")).hexdigest()[:12]
292
+ prefix = f"{kind}_{rel_hash}_"
293
+ out = self.cache_dir / f"{prefix}{sig_hash}_{params}.png"
294
+ with self.lock_for(str(out)):
295
+ if out.is_file() and out.stat().st_size > 0:
296
+ return out
297
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
298
+ with STRIP_JOBS:
299
+ image = build()
300
+ if file_signature(path) != sig:
301
+ raise RuntimeError("o arquivo mudou enquanto a imagem era gerada")
302
+ tmp = out.with_name(f".tmp-{os.getpid()}-{threading.get_ident()}-{out.name}")
303
+ image.save(tmp, format="PNG", compress_level=6)
304
+ os.replace(tmp, out)
305
+ current = []
306
+ for old in self.cache_dir.glob(f"{prefix}*.png"):
307
+ if not old.name.startswith(f"{prefix}{sig_hash}_"):
308
+ try:
309
+ old.unlink()
310
+ except OSError:
311
+ pass
312
+ elif old != out:
313
+ current.append(old)
314
+ # every size variant of this version stays cached, but only the newest few
315
+ current.sort(key=lambda f: f.stat().st_mtime if f.exists() else 0, reverse=True)
316
+ for old in current[STRIP_VARIANTS_KEPT - 1:]:
317
+ try:
318
+ old.unlink()
319
+ except OSError:
320
+ pass
321
+ return out
322
+
323
+ def waveform_png(self, rel: str, path: Path, width: int, height: int) -> Path:
324
+ return self._cache_png("waveform", rel, path, f"{width}x{height}",
325
+ lambda: render_waveform(path, width, height))
326
+
327
+ def filmstrip_png(self, rel: str, path: Path, count: int, height: int) -> Path:
328
+ return self._cache_png("filmstrip", rel, path, f"{count}x{height}",
329
+ lambda: render_filmstrip(path, count, height))
330
+
331
+
332
+ def allowed_host_names(host: str) -> set[str]:
333
+ """Names a browser may put in the Host header when the server listens beyond loopback."""
334
+ names = set(LOOPBACK_NAMES)
335
+ if host not in ("0.0.0.0", "::", ""):
336
+ names.add(host.lower())
337
+ names |= {ip for ip in lan_addresses()}
338
+ hostname = socket.gethostname().lower()
339
+ names |= {hostname, f"{hostname}.local"} if hostname else set()
340
+ return names
341
+
342
+
343
+ def lan_addresses() -> list[str]:
344
+ """IPv4 addresses of this computer on the local network (best effort, no traffic is sent)."""
345
+ found: list[str] = []
346
+ try:
347
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
348
+ sock.connect(("192.0.2.1", 9)) # TEST-NET address: picks the outgoing interface only
349
+ found.append(sock.getsockname()[0])
350
+ except OSError:
351
+ pass
352
+ try:
353
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
354
+ found.append(info[4][0])
355
+ except OSError:
356
+ pass
357
+ return [ip for ip in dict.fromkeys(found) if not ip.startswith("127.")]
358
+
359
+
360
+ def snap(value: int, step: int, lo: int, hi: int) -> int:
361
+ return max(lo, min(hi, int(round(value / step)) * step))
362
+
363
+
364
+ # ------------------------------------------------------------------ renderers
365
+
366
+
367
+ def render_waveform(path: Path, width: int, height: int):
368
+ """White waveform on transparent: faint peak envelope + solid RMS body, time-aligned to the container duration."""
369
+ import numpy as np
370
+ from PIL import Image
371
+
372
+ info = probe_video(path)
373
+ duration = float(info.get("duration") or 0.0)
374
+ rate = 8000 if duration <= 600 else 2000
375
+ alpha = np.zeros((height, width), dtype=np.uint8)
376
+ if info.get("has_audio") and duration > 0:
377
+ proc = subprocess.run(
378
+ ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-map", "0:a:0", "-ac", "1",
379
+ "-ar", str(rate), "-f", "s16le", "-acodec", "pcm_s16le", "-"],
380
+ capture_output=True,
381
+ )
382
+ samples = np.frombuffer(proc.stdout, dtype=np.int16).astype(np.float32) / 32768.0
383
+ total = max(width, int(round(duration * rate)))
384
+ if samples.size < total:
385
+ samples = np.pad(samples, (0, total - samples.size))
386
+ else:
387
+ samples = samples[:total]
388
+ edges = np.linspace(0, total, width + 1).astype(np.int64)
389
+ peaks = np.zeros(width, dtype=np.float32)
390
+ rms = np.zeros(width, dtype=np.float32)
391
+ abs_s = np.abs(samples)
392
+ sq = samples * samples
393
+ cum_sq = np.concatenate([[0.0], np.cumsum(sq, dtype=np.float64)])
394
+ for i in range(width):
395
+ a, b = edges[i], max(edges[i] + 1, edges[i + 1])
396
+ peaks[i] = abs_s[a:b].max(initial=0.0)
397
+ rms[i] = np.sqrt(max(0.0, (cum_sq[b] - cum_sq[a]) / (b - a)))
398
+ ref = float(np.percentile(peaks, 99.5)) if peaks.max() > 0 else 1.0
399
+ ref = max(ref, 0.02)
400
+ peaks_n = np.clip(peaks / ref, 0, 1) ** 0.75
401
+ rms_n = np.clip(rms / ref * 1.6, 0, 1) ** 0.75
402
+ half = (height - 2) / 2.0
403
+ center = (height - 1) / 2.0
404
+ dist = np.abs(np.arange(height, dtype=np.float32)[:, None] - center)
405
+ peak_h = np.maximum(peaks_n * half, 0.5)[None, :]
406
+ rms_h = np.maximum(rms_n * half, 0.5)[None, :]
407
+ alpha = np.where(dist <= rms_h, 235, np.where(dist <= peak_h, 95, 0)).astype(np.uint8)
408
+ else:
409
+ mid = height // 2
410
+ alpha[mid:mid + 1, :] = 90
411
+ rgba = np.zeros((height, width, 4), dtype=np.uint8)
412
+ rgba[..., :3] = 255
413
+ rgba[..., 3] = alpha
414
+ return Image.fromarray(rgba, mode="RGBA")
415
+
416
+
417
+ def render_filmstrip(path: Path, count: int, height: int):
418
+ """`count` evenly spaced thumbnails (centre of each slot) side by side, `height` px tall."""
419
+ import numpy as np
420
+ from PIL import Image
421
+
422
+ info = probe_video(path)
423
+ if not info.get("has_video"):
424
+ raise ValueError("o arquivo não tem vídeo")
425
+ duration = float(info.get("duration") or 0.0)
426
+ dw = int(info.get("display_width") or 1080)
427
+ dh = int(info.get("display_height") or 1920)
428
+ thumb_w = max(8, int(round(height * dw / max(1, dh))))
429
+ strip = Image.new("RGB", (thumb_w * count, height), (12, 13, 16))
430
+ if duration <= 0:
431
+ return strip
432
+ slot = duration / count
433
+ cmd = [
434
+ "ffmpeg", "-v", "error", "-nostdin", "-ss", f"{slot / 2:.4f}", "-i", str(path), "-map", "0:v:0",
435
+ "-vf", f"fps=fps={count}/{duration:.6f}:round=near,scale={thumb_w}:{height}:flags=area,format=rgb24",
436
+ "-frames:v", str(count), "-f", "rawvideo", "-pix_fmt", "rgb24", "-",
437
+ ]
438
+ proc = subprocess.run(cmd, capture_output=True)
439
+ frame_bytes = thumb_w * height * 3
440
+ n_frames = len(proc.stdout) // frame_bytes
441
+ if n_frames == 0:
442
+ raise ValueError("ffmpeg não devolveu quadros: " + proc.stderr.decode("utf-8", "replace")[-300:])
443
+ frames = np.frombuffer(proc.stdout[: n_frames * frame_bytes], dtype=np.uint8).reshape(n_frames, height, thumb_w, 3)
444
+ for i in range(count):
445
+ frame = frames[min(i, n_frames - 1)]
446
+ strip.paste(Image.fromarray(frame, mode="RGB"), (i * thumb_w, 0))
447
+ # thin dark separators between thumbs
448
+ arr = np.asarray(strip).copy()
449
+ for i in range(1, count):
450
+ arr[:, i * thumb_w - 1 : i * thumb_w, :] = (arr[:, i * thumb_w - 1 : i * thumb_w, :] * 0.35).astype(np.uint8)
451
+ return Image.fromarray(arr, mode="RGB")
452
+
453
+
454
+ # ------------------------------------------------------------------ validation
455
+
456
+
457
+ class BadRequest(Exception):
458
+ pass
459
+
460
+
461
+ def _num(value: Any, name: str) -> float:
462
+ try:
463
+ f = float(value)
464
+ except (TypeError, ValueError):
465
+ raise BadRequest(f"'{name}' precisa ser um número")
466
+ if f != f or f in (float("inf"), float("-inf")):
467
+ raise BadRequest(f"'{name}' precisa ser um número")
468
+ return round(max(0.0, f), 3)
469
+
470
+
471
+ def _text(value: Any, limit: int = 2000) -> str:
472
+ if value is None:
473
+ return ""
474
+ if not isinstance(value, (str, int, float)):
475
+ raise BadRequest("texto inválido")
476
+ return str(value).strip()[:limit]
477
+
478
+
479
+ def _phase(body: dict) -> str:
480
+ phase = body.get("phase")
481
+ if phase not in ("cut", "final"):
482
+ raise BadRequest("'phase' precisa ser \"cut\" ou \"final\"")
483
+ return phase
484
+
485
+
486
+ HEX_RE = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
487
+ DENOISE_CHOICES = ("off", "leve", "forte") # "Remover ruído de fundo" in the Estilo tab (edit_data audio.denoise)
488
+ TV_POSITIONS = ("esquerda", "direita", "centro") # tela verde: canto esquerdo, canto direito, meio
489
+ MUSIC_OFF = "off" # "Sem trilha"
490
+ MUSIC_FILE = "arquivo" # "Usar arquivo da pasta" (+ music_file)
491
+ MOOD_RE = re.compile(r"^[a-z0-9][a-z0-9_]{0,39}$")
492
+
493
+
494
+ def _accent(value: Any) -> str | None:
495
+ if value in (None, ""):
496
+ return None
497
+ if not isinstance(value, str) or not HEX_RE.match(value.strip()):
498
+ raise BadRequest("'accent' precisa ser uma cor no formato #RRGGBB")
499
+ h = value.strip().lstrip("#")
500
+ if len(h) == 3:
501
+ h = "".join(c * 2 for c in h)
502
+ return "#" + h.upper()
503
+
504
+
505
+ MOTION_QTD = {"poucas": 3, "algumas": 5} # style "motion_qtd" -> edit_data motion.quantidade
506
+ MOTION_TEMAS = ("papelaria", "papel_azul", "papel_verde", "papel_laranja", "papel_grafite", "pop", "neon",
507
+ "escuro", "claro") # the looks of motion.engine.THEME_NAMES
508
+
509
+
510
+ def validate_marks(body: dict) -> dict:
511
+ phase = _phase(body)
512
+ marks_in = body.get("marks")
513
+ if not isinstance(marks_in, list):
514
+ raise BadRequest("'marks' precisa ser uma lista")
515
+ if len(marks_in) > 500:
516
+ raise BadRequest("marcações demais (máximo 500)")
517
+ marks = []
518
+ for i, m in enumerate(marks_in):
519
+ if not isinstance(m, dict):
520
+ raise BadRequest(f"marcação {i + 1} inválida")
521
+ a, b = _num(m.get("in"), "in"), _num(m.get("out"), "out")
522
+ if b < a:
523
+ a, b = b, a
524
+ row = {"in": a, "out": b, "note": _text(m.get("note"), 1000)}
525
+ if m.get("kind") == "motion": # "Motion aqui": an animation asked for at this point
526
+ row["kind"] = "motion"
527
+ marks.append(row)
528
+ marks.sort(key=lambda m: (m["in"], m["out"]))
529
+ return {"phase": phase, "marks": marks, "saved_at": now_iso_ms()}
530
+
531
+
532
+ def validate_trims(body: dict) -> dict:
533
+ """Pedidos de esticar ou encolher a borda de um trecho, em segundos, relativos ao EDL de hoje."""
534
+ phase = _phase(body)
535
+ trims_in = body.get("trims")
536
+ if not isinstance(trims_in, list):
537
+ raise BadRequest("'trims' precisa ser uma lista")
538
+ if len(trims_in) > 200:
539
+ raise BadRequest("ajustes demais (máximo 200)")
540
+ seen: set[str] = set()
541
+ trims = []
542
+ for i, t in enumerate(trims_in):
543
+ if not isinstance(t, dict):
544
+ raise BadRequest(f"ajuste {i + 1} inválido")
545
+ rid = t.get("id")
546
+ if not isinstance(rid, str) or not rid.strip() or len(rid) > 60:
547
+ raise BadRequest(f"ajuste {i + 1}: 'id' do trecho inválido")
548
+ rid = rid.strip()
549
+ if rid in seen:
550
+ raise BadRequest(f"o trecho {rid} aparece duas vezes")
551
+ seen.add(rid)
552
+ row = {"id": rid}
553
+ for key in ("start_delta", "end_delta"):
554
+ raw = t.get(key)
555
+ if raw in (None, ""):
556
+ row[key] = 0.0
557
+ continue
558
+ try:
559
+ f = float(raw)
560
+ except (TypeError, ValueError):
561
+ raise BadRequest(f"ajuste {i + 1}: '{key}' precisa ser um número")
562
+ if f != f or f in (float("inf"), float("-inf")):
563
+ raise BadRequest(f"ajuste {i + 1}: '{key}' precisa ser um número")
564
+ row[key] = round(max(-2.0, min(2.0, f)), 3)
565
+ if not row["start_delta"] and not row["end_delta"]:
566
+ continue
567
+ for key in ("beat", "quote"):
568
+ if isinstance(t.get(key), str) and t[key].strip():
569
+ row[key] = t[key].strip()[:300]
570
+ trims.append(row)
571
+ return {"phase": phase, "trims": trims, "saved_at": now_iso_ms()}
572
+
573
+
574
+ def validate_approval(body: dict) -> dict:
575
+ record = {"phase": _phase(body), "approved": True, "note": _text(body.get("note"), 2000)}
576
+ # the exact video the person watched, so an approval never carries over to a newer render
577
+ if body.get("file") not in (None, ""):
578
+ if not isinstance(body.get("file"), str) or len(body["file"]) > 300:
579
+ raise BadRequest("'file' inválido")
580
+ record["file"] = body["file"].strip()
581
+ record["file_mtime"] = _num(body.get("file_mtime"), "file_mtime")
582
+ record["file_size"] = int(_num(body.get("file_size"), "file_size"))
583
+ record["saved_at"] = now_iso_ms()
584
+ return record
585
+
586
+
587
+ def _music_pick(body: dict, edit_dir: Path | None) -> tuple[str | None, str | None]:
588
+ """(music_mood, music_file) of the Trilha choice: None, "off", "arquivo" + a file in the edit, or a mood id."""
589
+ mood = body.get("music_mood")
590
+ if mood in (None, ""):
591
+ return None, None
592
+ if not isinstance(mood, str) or not (mood == MUSIC_OFF or mood == MUSIC_FILE or MOOD_RE.match(mood)):
593
+ raise BadRequest("'music_mood' precisa ser \"off\", \"arquivo\" ou o id de um humor da biblioteca")
594
+ if mood == MUSIC_OFF:
595
+ return mood, None
596
+ if mood == MUSIC_FILE:
597
+ rel = body.get("music_file")
598
+ if not isinstance(rel, str) or not rel.strip() or len(rel) > 300:
599
+ raise BadRequest("'music_file' precisa ser o arquivo de música da pasta edit")
600
+ rel = rel.strip().replace("\\", "/")
601
+ if edit_dir is not None:
602
+ path = (edit_dir / rel).resolve()
603
+ if (edit_dir not in path.parents) or not path.is_file() or path.suffix.lower() not in trilhas.AUDIO_EXT:
604
+ raise BadRequest("'music_file' não é um arquivo de música da pasta edit")
605
+ return mood, rel
606
+ known = {m["id"] for m in trilhas.moods_summary().get("moods") or []}
607
+ if mood not in known:
608
+ raise BadRequest(f"o humor '{mood}' não tem trilha na biblioteca")
609
+ return mood, None
610
+
611
+
612
+ def validate_style(body: dict, edit_dir: Path | None = None) -> dict:
613
+ def opt_id(key: str) -> str | None:
614
+ v = body.get(key)
615
+ if v in (None, ""):
616
+ return None
617
+ if not isinstance(v, str) or len(v) > 80:
618
+ raise BadRequest(f"'{key}' inválido")
619
+ return v.strip()
620
+
621
+ denoise = body.get("denoise")
622
+ if denoise in (None, ""):
623
+ denoise = None
624
+ elif denoise not in DENOISE_CHOICES:
625
+ raise BadRequest("'denoise' precisa ser \"off\", \"leve\" ou \"forte\"")
626
+
627
+ elements_in = body.get("elements") or {}
628
+ if not isinstance(elements_in, dict):
629
+ raise BadRequest("'elements' precisa ser um objeto {id: true/false}")
630
+ elements = {}
631
+ for k, v in elements_in.items():
632
+ if not isinstance(k, str) or len(k) > 80:
633
+ raise BadRequest("id de elemento inválido")
634
+ elements[k] = bool(v)
635
+ music_mood, music_file = _music_pick(body, edit_dir)
636
+ if music_mood is not None:
637
+ elements["music"] = music_mood != MUSIC_OFF
638
+ edit_type = opt_id("edit_type")
639
+ tv_pos = body.get("tela_verde_posicao")
640
+ if tv_pos in (None, ""):
641
+ tv_pos = None
642
+ elif tv_pos not in TV_POSITIONS:
643
+ raise BadRequest("'tela_verde_posicao' precisa ser \"esquerda\", \"direita\" ou \"centro\"")
644
+ motion_qtd, motion_tema = body.get("motion_qtd"), body.get("motion_tema")
645
+ if motion_qtd not in (None, "") and motion_qtd not in MOTION_QTD:
646
+ raise BadRequest("'motion_qtd' precisa ser \"poucas\" ou \"algumas\"")
647
+ if motion_tema not in (None, "") and motion_tema not in MOTION_TEMAS:
648
+ raise BadRequest(f"'motion_tema' precisa ser um destes: {', '.join(MOTION_TEMAS)}")
649
+ record = {
650
+ "edit_type": edit_type,
651
+ "caption_style": opt_id("caption_style"),
652
+ "hook_style": opt_id("hook_style"),
653
+ "accent": _accent(body.get("accent")),
654
+ "hook_text": _text(body.get("hook_text"), 300),
655
+ "cta_text": _text(body.get("cta_text"), 300),
656
+ "elements": elements,
657
+ "denoise": denoise,
658
+ "music_mood": music_mood,
659
+ "note": _text(body.get("note"), 2000),
660
+ "saved_at": now_iso_ms(),
661
+ }
662
+ if music_file:
663
+ record["music_file"] = music_file
664
+ if elements.get("motion") and motion_qtd:
665
+ record["motion_qtd"] = motion_qtd
666
+ record["motion_tema"] = motion_tema or "papelaria"
667
+ if edit_type == "tela_verde":
668
+ record["tela_verde_posicao"] = tv_pos or TV_POSITIONS[1]
669
+ moved = _split_inserts(edit_dir)
670
+ if moved is not None:
671
+ record["tela_verde_inserts_fundo"] = moved
672
+ return record
673
+
674
+
675
+ def _split_inserts(edit_dir: Path | None) -> int | None:
676
+ """How many split_top / split_bottom inserts edit_data.json plans (in tela verde they become the background;
677
+ the file is only read). None without a readable edit_data.json."""
678
+ if edit_dir is None:
679
+ return None
680
+ try:
681
+ data = json.loads((edit_dir / "edit_data.json").read_text(encoding="utf-8"))
682
+ except (OSError, ValueError):
683
+ return None
684
+ items = data.get("inserts") if isinstance(data, dict) else None
685
+ if not isinstance(items, list):
686
+ return 0
687
+ return sum(1 for it in items if isinstance(it, dict) and it.get("type") in ("split_top", "split_bottom"))
688
+
689
+
690
+ # ------------------------------------------------------------------ HTTP
691
+
692
+
693
+ RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
694
+
695
+
696
+ class Handler(BaseHTTPRequestHandler):
697
+ protocol_version = "HTTP/1.1"
698
+ server_version = "EditorVideoPreview/1.0"
699
+ session: Session # set on the subclass
700
+
701
+ # quiet by default; errors still go to stderr
702
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
703
+ if getattr(self.server, "verbose", False):
704
+ sys.stderr.write("[preview] " + (fmt % args) + "\n")
705
+
706
+ # -- helpers
707
+
708
+ def _send_bytes(self, status: int, body: bytes, ctype: str, extra: dict | None = None, head: bool = False) -> None:
709
+ self.send_response(status)
710
+ self.send_header("Content-Type", ctype)
711
+ self.send_header("Content-Length", str(len(body)))
712
+ for k, v in (extra or {}).items():
713
+ self.send_header(k, v)
714
+ if status >= 400 or self.command == "POST":
715
+ # never reuse a connection after a refusal or a write: leftover bytes must not become a request
716
+ self.send_header("Connection", "close")
717
+ self.close_connection = True
718
+ self.end_headers()
719
+ if not head:
720
+ try:
721
+ self.wfile.write(body)
722
+ except (BrokenPipeError, ConnectionResetError):
723
+ pass
724
+
725
+ def _json(self, status: int, obj: Any, head: bool = False) -> None:
726
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
727
+ self._send_bytes(status, body, "application/json; charset=utf-8", {"Cache-Control": "no-store"}, head)
728
+
729
+ def _error(self, status: int, message: str, head: bool = False) -> None:
730
+ self._json(status, {"ok": False, "error": message}, head)
731
+
732
+ def _host_ok(self) -> bool:
733
+ """The Host header must name this computer and this port (blocks DNS rebinding)."""
734
+ host = (self.headers.get("Host") or "").strip().lower()
735
+ if not host:
736
+ return False
737
+ if host.startswith("["):
738
+ name, _, rest = host.partition("]")
739
+ name, port = name + "]", rest.lstrip(":")
740
+ else:
741
+ name, _, port = host.partition(":")
742
+ if port and port != str(self.session.port):
743
+ return False
744
+ return name in self.session.allowed_names
745
+
746
+ def _origin_ok(self) -> bool:
747
+ origin = self.headers.get("Origin")
748
+ if origin is None:
749
+ return True
750
+ host = self.headers.get("Host") or ""
751
+ return origin == f"http://{host}"
752
+
753
+ def _site_ok(self) -> bool:
754
+ """Browsers say who started the request: another site may not read or write the session."""
755
+ return (self.headers.get("Sec-Fetch-Site") or "").strip().lower() not in ("cross-site", "same-site")
756
+
757
+ def _token_in_request(self) -> str:
758
+ for part in (self.headers.get("Cookie") or "").split(";"):
759
+ name, _, value = part.strip().partition("=")
760
+ if name == TOKEN_COOKIE:
761
+ return value
762
+ return ""
763
+
764
+ def _gate_ok(self, query: dict) -> bool:
765
+ """Listening beyond loopback: every route but the entry page needs the token (cookie or ?t=)."""
766
+ if self.session.loopback:
767
+ return True
768
+ given = (query.get("t") or [""])[0] or self._token_in_request() or (self.headers.get(TOKEN_HEADER) or "")
769
+ return bool(given) and hmac.compare_digest(given, self.session.token)
770
+
771
+ # -- verbs
772
+
773
+ def do_HEAD(self) -> None: # noqa: N802
774
+ self._dispatch_get(head=True)
775
+
776
+ def do_GET(self) -> None: # noqa: N802
777
+ self._dispatch_get(head=False)
778
+
779
+ def _dispatch_get(self, head: bool) -> None:
780
+ try:
781
+ if self.headers.get("Content-Length") not in (None, "", "0") or self.headers.get("Transfer-Encoding"):
782
+ self.close_connection = True # a GET with a body is never ours
783
+ return self._error(400, "requisição inválida", head)
784
+ if not self._host_ok():
785
+ return self._error(403, "host não permitido", head)
786
+ parsed = urllib.parse.urlsplit(self.path)
787
+ route = parsed.path
788
+ query = urllib.parse.parse_qs(parsed.query)
789
+ if route in ("/", "/index.html") and not self.session.loopback:
790
+ given = (query.get("t") or [""])[0]
791
+ if given and hmac.compare_digest(given, self.session.token):
792
+ self.send_response(303)
793
+ self.send_header("Location", "/")
794
+ self.send_header("Set-Cookie", f"{TOKEN_COOKIE}={self.session.token}; Path=/; HttpOnly; SameSite=Strict")
795
+ self.send_header("Content-Length", "0")
796
+ self.end_headers()
797
+ return None
798
+ if not self._gate_ok(query):
799
+ return self._error(403, "abra o link completo que o agente mostrou (com ?t=...)", head)
800
+ if route.startswith(("/api/", "/media/")) and not self._site_ok():
801
+ return self._error(403, "pedido vindo de outro site", head)
802
+ if route in STATIC_FILES:
803
+ return self._static(route, head)
804
+ if route == "/favicon.ico":
805
+ return self._send_bytes(204, b"", "image/x-icon", head=head)
806
+ if route == "/api/health":
807
+ return self._json(200, {"ok": True, "edit_dir": str(self.session.edit_dir)}, head)
808
+ if route == "/api/state":
809
+ return self._json(200, self.session.api_state(), head)
810
+ if route == "/api/trilhas":
811
+ given = self.headers.get(TOKEN_HEADER) or ""
812
+ if not self._origin_ok() or not given or not hmac.compare_digest(given, self.session.token):
813
+ return self._error(403, "token do preview inválido: recarregue a página", head)
814
+ return self._json(200, self.session.api_trilhas(), head)
815
+ if route.startswith("/media/"):
816
+ return self._media(urllib.parse.unquote(route[len("/media/"):]), head)
817
+ if route == "/api/waveform":
818
+ return self._strip("waveform", query, head)
819
+ if route == "/api/filmstrip":
820
+ return self._strip("filmstrip", query, head)
821
+ return self._error(404, "não encontrado", head)
822
+ except (BrokenPipeError, ConnectionResetError):
823
+ pass
824
+ except Exception as exc: # keep the server alive, report in PT-BR
825
+ sys.stderr.write(f"[preview] erro em GET {self.path}: {exc!r}\n")
826
+ try:
827
+ self._error(500, f"erro interno: {exc}", head)
828
+ except Exception:
829
+ pass
830
+
831
+ def do_POST(self) -> None: # noqa: N802
832
+ try:
833
+ self.close_connection = True
834
+ if self.headers.get("Transfer-Encoding"):
835
+ return self._error(400, "envie o corpo com Content-Length")
836
+ try:
837
+ length = int(self.headers.get("Content-Length") or 0)
838
+ except ValueError:
839
+ return self._error(400, "Content-Length inválido")
840
+ if length < 0 or length > MAX_BODY:
841
+ return self._error(413 if length > MAX_BODY else 400, "corpo vazio ou grande demais")
842
+ raw = self.rfile.read(length) if length else b"" # read it all before any refusal
843
+ if not self._host_ok() or not self._origin_ok() or not self._site_ok():
844
+ return self._error(403, "origem não permitida")
845
+ given = self.headers.get(TOKEN_HEADER) or ""
846
+ if not given or not hmac.compare_digest(given, self.session.token):
847
+ return self._error(403, "token do preview inválido: recarregue a página")
848
+ route = urllib.parse.urlsplit(self.path).path
849
+ ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
850
+ if ctype != "application/json":
851
+ return self._error(415, "envie JSON (Content-Type: application/json)")
852
+ if not raw:
853
+ return self._error(400, "corpo vazio ou grande demais")
854
+ try:
855
+ body = json.loads(raw.decode("utf-8"))
856
+ except (UnicodeDecodeError, json.JSONDecodeError):
857
+ return self._error(400, "JSON inválido")
858
+ if not isinstance(body, dict):
859
+ return self._error(400, "o corpo precisa ser um objeto JSON")
860
+ targets = {
861
+ "/api/marks": ("preview_marks.json", validate_marks),
862
+ "/api/trims": ("preview_trims.json", validate_trims),
863
+ "/api/approve": ("preview_approval.json", validate_approval),
864
+ "/api/style": ("preview_style.json", validate_style),
865
+ }
866
+ if route not in targets:
867
+ return self._error(404, "não encontrado")
868
+ filename, validate = targets[route]
869
+ try:
870
+ record = validate(body, self.session.edit_dir) if validate is validate_style else validate(body)
871
+ except BadRequest as exc:
872
+ return self._error(400, str(exc))
873
+ try:
874
+ save_json(self.session.edit_dir / filename, record)
875
+ except OSError as exc:
876
+ return self._error(500, f"não consegui salvar {filename}: {exc}")
877
+ return self._json(200, {"ok": True, "file": filename, "saved": record})
878
+ except (BrokenPipeError, ConnectionResetError):
879
+ pass
880
+ except Exception as exc:
881
+ sys.stderr.write(f"[preview] erro em POST {self.path}: {exc!r}\n")
882
+ try:
883
+ self._error(500, f"erro interno: {exc}")
884
+ except Exception:
885
+ pass
886
+
887
+ # -- routes
888
+
889
+ def _static(self, route: str, head: bool) -> None:
890
+ name, ctype = STATIC_FILES[route]
891
+ path = PREVIEW_DIR / name
892
+ if not path.is_file():
893
+ return self._error(404, f"arquivo da interface não encontrado: preview/{name}", head)
894
+ body = path.read_bytes()
895
+ extra = {"Cache-Control": "no-cache"}
896
+ if name == "index.html":
897
+ extra["Content-Security-Policy"] = CSP
898
+ extra["X-Content-Type-Options"] = "nosniff"
899
+ extra["Referrer-Policy"] = "no-referrer"
900
+ self._send_bytes(200, body, ctype, extra, head)
901
+
902
+ def _media(self, rel: str, head: bool) -> None:
903
+ path = self.session.safe_path(rel)
904
+ if path is None:
905
+ return self._error(403, "caminho fora da pasta de edição", head)
906
+ if not path.is_file():
907
+ return self._error(404, f"arquivo não encontrado: {rel}", head)
908
+ st = path.stat()
909
+ size = st.st_size
910
+ ctype = MEDIA_TYPES.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
911
+ etag = f'"{st.st_size:x}-{st.st_mtime_ns:x}"'
912
+ common_headers = {
913
+ "Accept-Ranges": "bytes",
914
+ "ETag": etag,
915
+ "Last-Modified": self.date_time_string(st.st_mtime),
916
+ "Cache-Control": "no-cache",
917
+ }
918
+ start, end = 0, size - 1
919
+ status = 200
920
+ range_header = self.headers.get("Range")
921
+ if_range = self.headers.get("If-Range")
922
+ if range_header and (not if_range or if_range == etag):
923
+ first = range_header.split(",")[0].strip()
924
+ m = RANGE_RE.match(first)
925
+ if not m or (m.group(1) == "" and m.group(2) == ""):
926
+ return self._range_not_satisfiable(size, head)
927
+ if m.group(1) == "":
928
+ suffix = int(m.group(2))
929
+ if suffix == 0:
930
+ return self._range_not_satisfiable(size, head)
931
+ start = max(0, size - suffix)
932
+ else:
933
+ start = int(m.group(1))
934
+ if m.group(2) != "":
935
+ end = min(int(m.group(2)), size - 1)
936
+ if start >= size or start > end:
937
+ return self._range_not_satisfiable(size, head)
938
+ status = 206
939
+ length = end - start + 1 if size > 0 else 0
940
+ self.send_response(status)
941
+ self.send_header("Content-Type", ctype)
942
+ self.send_header("Content-Length", str(length))
943
+ if status == 206:
944
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
945
+ for k, v in common_headers.items():
946
+ self.send_header(k, v)
947
+ self.end_headers()
948
+ if head or length == 0:
949
+ return
950
+ try:
951
+ with path.open("rb") as fh:
952
+ fh.seek(start)
953
+ remaining = length
954
+ while remaining > 0:
955
+ chunk = fh.read(min(CHUNK, remaining))
956
+ if not chunk:
957
+ break
958
+ self.wfile.write(chunk)
959
+ remaining -= len(chunk)
960
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
961
+ # the player cancels range requests all the time while scrubbing
962
+ self.close_connection = True
963
+
964
+ def _range_not_satisfiable(self, size: int, head: bool) -> None:
965
+ body = json.dumps({"ok": False, "error": "intervalo inválido"}).encode("utf-8")
966
+ self._send_bytes(416, body, "application/json; charset=utf-8",
967
+ {"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"}, head)
968
+
969
+ def _strip(self, kind: str, query: dict, head: bool) -> None:
970
+ rel = (query.get("file") or [""])[0]
971
+ if not rel:
972
+ return self._error(400, "informe ?file=", head)
973
+ path = self.session.safe_path(rel)
974
+ if path is None:
975
+ return self._error(403, "caminho fora da pasta de edição", head)
976
+ if not path.is_file():
977
+ return self._error(404, f"arquivo não encontrado: {rel}", head)
978
+
979
+ def qint(name: str, default: int, lo: int, hi: int) -> int:
980
+ try:
981
+ return max(lo, min(hi, int(float((query.get(name) or [default])[0]))))
982
+ except (TypeError, ValueError):
983
+ return default
984
+
985
+ try:
986
+ # sizes snap to the grid the page asks for, so no one can make endless variants
987
+ if kind == "waveform":
988
+ png = self.session.waveform_png(rel, path, snap(qint("w", 2000, 64, 8000), 250, 500, 4000),
989
+ snap(qint("h", 104, 16, 800), 8, 16, 256))
990
+ else:
991
+ png = self.session.filmstrip_png(rel, path, snap(qint("n", 40, 1, 240), 4, 4, 160),
992
+ snap(qint("h", 90, 16, 400), 16, 32, 192))
993
+ except Exception as exc:
994
+ sys.stderr.write(f"[preview] falha ao gerar {kind} de {rel}: {exc}\n")
995
+ return self._error(422, f"não consegui gerar a imagem ({kind}) de {rel}", head)
996
+ body = png.read_bytes()
997
+ self._send_bytes(200, body, "image/png", {"Cache-Control": "private, max-age=31536000, immutable"
998
+ if query.get("v") else "no-cache"}, head)
999
+
1000
+
1001
+ # ------------------------------------------------------------------ startup
1002
+
1003
+
1004
+ def check_writable(edit_dir: Path) -> str | None:
1005
+ """Return a PT-BR error message when the edit dir can't be created or written."""
1006
+ try:
1007
+ edit_dir.mkdir(parents=False, exist_ok=True)
1008
+ (edit_dir / "preview_cache").mkdir(exist_ok=True)
1009
+ probe = edit_dir / "preview_cache" / f".write-test-{os.getpid()}"
1010
+ with probe.open("w", encoding="utf-8") as fh:
1011
+ fh.write("ok")
1012
+ probe.unlink()
1013
+ return None
1014
+ except FileNotFoundError:
1015
+ return f"A pasta onde ficaria a edição não existe: {edit_dir.parent}"
1016
+ except OSError as exc:
1017
+ if exc.errno == errno.EPERM:
1018
+ return (f"O macOS bloqueou a gravação em {edit_dir} (Operation not permitted). "
1019
+ f"Isso quase sempre é a proteção de privacidade. {PRIVACY_HINT}")
1020
+ if exc.errno == errno.EACCES:
1021
+ return (f"Sem permissão para gravar em {edit_dir}. Confira se a pasta permite escrita "
1022
+ f"para o seu usuário. Também pode ser a proteção de privacidade do macOS. {PRIVACY_HINT}")
1023
+ if exc.errno == errno.EROFS:
1024
+ return f"A pasta {edit_dir} está num disco só de leitura. Copie os vídeos para outro lugar."
1025
+ return f"Não consegui gravar em {edit_dir}: {exc}"
1026
+
1027
+
1028
+ def write_server_info(edit_dir: Path, info: dict) -> None:
1029
+ """preview_cache/server.json: where the agent reads the URL (or the startup error) on any system."""
1030
+ try:
1031
+ save_json(edit_dir / "preview_cache" / "server.json", {**info, "updated_at": now_iso_ms()})
1032
+ except OSError:
1033
+ pass
1034
+
1035
+
1036
+ def reusable_preview(edit_dir: Path) -> dict | None:
1037
+ """server.json of a preview of this same edit dir that still answers."""
1038
+ try:
1039
+ info = load_json(edit_dir / "preview_cache" / "server.json") or {}
1040
+ except (json.JSONDecodeError, OSError):
1041
+ return None
1042
+ if info.get("status") != "running" or not isinstance(info.get("port"), int):
1043
+ return None
1044
+ other = existing_preview("127.0.0.1", info["port"])
1045
+ return info if other and Path(other).resolve() == edit_dir.resolve() else None
1046
+
1047
+
1048
+ def existing_preview(host: str, port: int) -> str | None:
1049
+ """edit_dir of a preview already answering on host:port, if any."""
1050
+ url_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
1051
+ try:
1052
+ with urllib.request.urlopen(f"http://{url_host}:{port}/api/health", timeout=0.8) as resp:
1053
+ data = json.loads(resp.read().decode("utf-8"))
1054
+ return data.get("edit_dir") if data.get("ok") else None
1055
+ except (urllib.error.URLError, OSError, ValueError):
1056
+ return None
1057
+
1058
+
1059
+ def main() -> int:
1060
+ parser = argparse.ArgumentParser(
1061
+ description="Preview local da edição: player com linha do tempo, marcações, aprovação e escolha de estilo.",
1062
+ )
1063
+ parser.add_argument("--edit-dir", required=True, help="pasta edit/ da sessão (fica ao lado dos vídeos)")
1064
+ parser.add_argument("--port", type=int, default=None,
1065
+ help=f"porta (padrão {DEFAULT_PORT}; sem --port, reaproveita o preview desta edição se já estiver rodando)")
1066
+ parser.add_argument("--host", default="127.0.0.1",
1067
+ help="endereço (padrão 127.0.0.1, só este computador). Outro valor abre para a rede e exige o link com token")
1068
+ parser.add_argument("--open", action="store_true", help="abre o navegador ao iniciar")
1069
+ parser.add_argument("--catalog", default=None, help="catálogo de estilos alternativo (testes); padrão helpers/style_catalog.json")
1070
+ parser.add_argument("--verbose", action="store_true", help="mostra cada requisição no terminal")
1071
+ args = parser.parse_args()
1072
+ utf8_console()
1073
+ port_given = args.port is not None
1074
+ if not port_given:
1075
+ args.port = DEFAULT_PORT
1076
+
1077
+ edit_dir = Path(args.edit_dir).expanduser()
1078
+ if not edit_dir.is_absolute():
1079
+ edit_dir = Path.cwd() / edit_dir
1080
+ if edit_dir.exists() and not edit_dir.is_dir():
1081
+ print(f"Erro: {edit_dir} não é uma pasta.", file=sys.stderr)
1082
+ return 2
1083
+ problem = check_writable(edit_dir)
1084
+ if problem:
1085
+ print(f"Erro: {problem}", file=sys.stderr)
1086
+ return 2
1087
+
1088
+ def fail(message: str) -> int:
1089
+ print(f"Erro: {message}", file=sys.stderr)
1090
+ write_server_info(edit_dir, {"status": "error", "error": message, "edit_dir": str(edit_dir.resolve()), "pid": os.getpid()})
1091
+ return 2
1092
+
1093
+ if not (PREVIEW_DIR / "index.html").is_file():
1094
+ return fail(f"a interface do preview não foi encontrada em {PREVIEW_DIR}. Reinstale a skill.")
1095
+ catalog_path = Path(args.catalog).expanduser().resolve() if args.catalog else STYLE_CATALOG_PATH
1096
+ if args.catalog and not catalog_path.is_file():
1097
+ return fail(f"catálogo não encontrado: {catalog_path}")
1098
+ if not port_given:
1099
+ running = reusable_preview(edit_dir)
1100
+ if running:
1101
+ log(f"O preview desta edição já está rodando em {running.get('url')}")
1102
+ write_server_info(edit_dir, {**running, "reused": True})
1103
+ if args.open and running.get("url"):
1104
+ webbrowser.open(str(running["url"]))
1105
+ return 0
1106
+
1107
+ session = Session(edit_dir, catalog_path, args.host, args.port)
1108
+ handler = type("BoundHandler", (Handler,), {"session": session})
1109
+
1110
+ httpd = None
1111
+ port = args.port
1112
+ for attempt in range(20):
1113
+ try:
1114
+ httpd = ThreadingHTTPServer((args.host, port), handler)
1115
+ break
1116
+ except OSError as exc:
1117
+ if exc.errno not in (errno.EADDRINUSE, errno.EACCES, getattr(errno, "WSAEADDRINUSE", -1), getattr(errno, "WSAEACCES", -1)):
1118
+ return fail(f"não consegui abrir o servidor em {args.host}:{port}: {exc}")
1119
+ other = existing_preview(args.host, port)
1120
+ if other and Path(other).resolve() == session.edit_dir:
1121
+ url = f"http://{'127.0.0.1' if args.host in ('0.0.0.0', '::') else args.host}:{port}"
1122
+ log(f"O preview desta edição já está rodando em {url}")
1123
+ write_server_info(edit_dir, {"status": "running", "url": url, "port": port, "host": args.host,
1124
+ "edit_dir": str(session.edit_dir), "reused": True})
1125
+ if args.open:
1126
+ webbrowser.open(url)
1127
+ return 0
1128
+ port += 1
1129
+ if httpd is None:
1130
+ return fail(f"as portas {args.port} a {port - 1} estão ocupadas. Use --port com outra porta.")
1131
+
1132
+ httpd.daemon_threads = True
1133
+ httpd.verbose = args.verbose # type: ignore[attr-defined]
1134
+ session.port = port
1135
+ shown_host = "127.0.0.1" if args.host in ("0.0.0.0", "::") else args.host
1136
+ url = f"http://{shown_host}:{port}"
1137
+ note = f" (a porta {args.port} estava ocupada)" if port != args.port else ""
1138
+ info = {"status": "running", "url": url, "port": port, "host": args.host, "pid": os.getpid(),
1139
+ "edit_dir": str(session.edit_dir), "started_at": now_iso_ms()}
1140
+ if not session.loopback:
1141
+ lan = [ip for ip in lan_addresses()] or [shown_host]
1142
+ url = f"http://{shown_host}:{port}/?t={session.token}"
1143
+ info["url"] = url
1144
+ info["lan_urls"] = [f"http://{ip}:{port}/?t={session.token}" for ip in lan]
1145
+ write_server_info(edit_dir, info)
1146
+ log(f"Preview rodando em {url}{note}")
1147
+ if not session.loopback:
1148
+ log(f"Aviso: o preview está aberto para a rede ({args.host}). Qualquer aparelho da mesma rede com este link "
1149
+ "pode ver o vídeo e aprovar. Use só numa rede de confiança e pare com Ctrl+C quando terminar.")
1150
+ for lan_url in info["lan_urls"]:
1151
+ log(f"No celular (mesma rede): {lan_url}")
1152
+ log(f"Pasta da edição: {session.edit_dir}")
1153
+ if not catalog_path.is_file():
1154
+ log("Aviso: helpers/style_catalog.json ainda não existe; a aba Estilo fica vazia até ele aparecer.")
1155
+ log("Para parar: Ctrl+C")
1156
+ if args.open:
1157
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
1158
+
1159
+ def stop(_signum, _frame):
1160
+ raise KeyboardInterrupt
1161
+
1162
+ try:
1163
+ signal.signal(signal.SIGTERM, stop) # a killed server still marks server.json as stopped
1164
+ except (ValueError, OSError, AttributeError):
1165
+ pass
1166
+ try:
1167
+ httpd.serve_forever(poll_interval=0.5)
1168
+ except KeyboardInterrupt:
1169
+ log("Preview parado.")
1170
+ finally:
1171
+ httpd.server_close()
1172
+ write_server_info(edit_dir, {**info, "status": "stopped"})
1173
+ return 0
1174
+
1175
+
1176
+ if __name__ == "__main__":
1177
+ sys.exit(main())