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