primocode 9.8.0-beta.9 → 9.8.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/README.md +2 -2
- package/lib/act.js +16 -9
- package/lib/api.js +8 -1
- package/lib/catalogo.js +11 -3
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +399 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +854 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/package.json +2 -2
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
package/lib/local/entrada.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ── A IDEIA ──────────────────────────────────────────────────────────────
|
|
19
19
|
* Ninguém precisa das 45 ferramentas para fazer um slide. Quem pede um vídeo
|
|
20
|
-
* não usa `desktop_click`; quem conserta um bug não usa `
|
|
20
|
+
* não usa `desktop_click`; quem conserta um bug não usa `estudio_render`. Então
|
|
21
21
|
* cada pedido leva só o que aquele pedido pode usar — ferramentas E seções do
|
|
22
22
|
* prompt — e o que sobra do orçamento vira espaço para a conversa.
|
|
23
23
|
*
|
|
@@ -74,10 +74,19 @@ const GRUPOS = [
|
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
nome: 'estudio',
|
|
77
|
-
tools: ['
|
|
78
|
-
'
|
|
79
|
-
// O que o estúdio produz, e os apps que ele substitui.
|
|
80
|
-
|
|
77
|
+
tools: ['estudio_projeto', 'estudio_plano', 'estudio_midia', 'estudio_narrar',
|
|
78
|
+
'estudio_render', 'estudio_site', 'planejar_producao'],
|
|
79
|
+
// O que o estúdio produz, e os apps que ele substitui. Documento,
|
|
80
|
+
// planilha e PDF saíram: o Estúdio novo faz vídeo, slides e arte, e o
|
|
81
|
+
// resto é arquivo escrito com código.
|
|
82
|
+
gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|motion|vinheta|teaser|an[úu]ncio|post|banner|capa|thumbnail|arte|cartaz|narra[çc][ãa]o|trilha|legenda|grava[çc][ãa]o|est[úu]dio|canva|powerpoint|keynote|photoshop|figma|capcut|premiere)\b/i,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
/* A GRAVAÇÃO do usuário é outra ferramenta, e só entra quando o pedido
|
|
86
|
+
fala dela: "faz um vídeo" não precisa cortar silêncio de ninguém. */
|
|
87
|
+
nome: 'gravacao',
|
|
88
|
+
tools: ['estudio_video'],
|
|
89
|
+
gatilho: /\b(grava[çc][ãa]o|gravei|filmagem|filmei|meu v[íi]deo|edit\w* (o |a |meu |minha |esse |essa |este |esta )?(v[íi]deo|grava[çc][ãa]o)|\.mp4|\.mov|legendas?|cort(a|ar|e) (o )?sil[êe]ncio|recort(a|ar|e) (a )?pessoa|transcrev\w*|importa\w* (o )?v[íi]deo)\b/i,
|
|
81
90
|
},
|
|
82
91
|
{
|
|
83
92
|
nome: 'navegador',
|
|
@@ -118,7 +127,7 @@ const GRUPOS = [
|
|
|
118
127
|
quando o pedido fala do arquivo — é o que mantém o pedido de vídeo
|
|
119
128
|
dentro do teto do minuto (medido: ela custa ~180 tokens). */
|
|
120
129
|
nome: 'exportar',
|
|
121
|
-
tools: ['
|
|
130
|
+
tools: ['estudio_render'],
|
|
122
131
|
gatilho: /\b(mp4|webm|baix[ae]r?|download|exporta\w*|salv[ae]r? (o )?v[íi]deo|arquivo (do|de) v[íi]deo|me manda o v[íi]deo|manda o arquivo|renderiza\w*)\b/i,
|
|
123
132
|
},
|
|
124
133
|
{
|
|
@@ -196,7 +205,8 @@ function jaAprendeu(messages) {
|
|
|
196
205
|
if (m && m.role === 'tool' && nomePorId.has(m.tool_call_id)) feitas.add(nomePorId.get(m.tool_call_id));
|
|
197
206
|
}
|
|
198
207
|
const dispensaveis = new Set();
|
|
199
|
-
|
|
208
|
+
// O Estúdio novo não tem spec para ler: a seção dele é o único mapa do
|
|
209
|
+
// fluxo, e fica até o fim.
|
|
200
210
|
if (feitas.has('pesquisar_referencia')) dispensaveis.add('pesquisa');
|
|
201
211
|
return dispensaveis;
|
|
202
212
|
}
|
|
@@ -253,7 +263,7 @@ function relatorio({ prompt, ferramentas, messages }) {
|
|
|
253
263
|
// O CLI já protege as mesmas mensagens antes de mandar (lib/repeticao.js), mas
|
|
254
264
|
// quem falava por último aqui era `caberNoTeto`, que não sabia disso e cortava
|
|
255
265
|
// assim mesmo. Dois compactadores brigando, e o do servidor ganhava.
|
|
256
|
-
const REFERENCIAS = new Set(['
|
|
266
|
+
const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
|
|
257
267
|
|
|
258
268
|
// Referência tem teto GENEROSO, não infinito.
|
|
259
269
|
//
|
package/lib/local/motor-cli.js
CHANGED
|
@@ -37,7 +37,7 @@ function motorDe(id) {
|
|
|
37
37
|
function perguntar(id, texto) {
|
|
38
38
|
if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
|
|
39
39
|
const claude = motorDe('claude-code');
|
|
40
|
-
const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.
|
|
40
|
+
const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
|
|
41
41
|
encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
|
|
42
42
|
});
|
|
43
43
|
try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "9.8.0
|
|
3
|
+
"version": "9.8.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
package/studio/narrar.py
CHANGED
|
@@ -200,19 +200,22 @@ def falar_edge(texto, saida, voz, ritmo, tom):
|
|
|
200
200
|
|
|
201
201
|
def falar_elevenlabs(texto, saida, voz):
|
|
202
202
|
"""Só quando há chave. Sem ela, nem se tenta — e não é erro."""
|
|
203
|
-
chave
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
# A chave mora na variável OU em ~/.primocode/elevenlabs.key — antes só a
|
|
204
|
+
# variável era lida, e a função chamada (elevenlabs.falar) nem existia:
|
|
205
|
+
# a ElevenLabs nunca narrou nada, mesmo com a chave no lugar.
|
|
206
206
|
try:
|
|
207
207
|
sys.path.insert(0, str(AQUI))
|
|
208
|
-
import elevenlabs
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
import elevenlabs
|
|
209
|
+
if not elevenlabs.configurado():
|
|
210
|
+
return None
|
|
211
|
+
dados, segundos = elevenlabs.sintetizar(texto, {"voz": voz})
|
|
212
|
+
if not dados:
|
|
213
|
+
return None
|
|
214
|
+
Path(saida).write_bytes(dados)
|
|
215
|
+
return {"ok": True, "arquivo": str(saida), "motor": "elevenlabs", "segundos": segundos,
|
|
216
|
+
"falas": [{"de": 0, "ate": segundos, "texto": texto}]}
|
|
213
217
|
except Exception:
|
|
214
|
-
|
|
215
|
-
return None
|
|
218
|
+
return None
|
|
216
219
|
|
|
217
220
|
|
|
218
221
|
def comando_falar(args):
|
|
@@ -245,6 +248,97 @@ def comando_falar(args):
|
|
|
245
248
|
return falar_edge(texto, args.saida, v["voz"], ritmo, tom)
|
|
246
249
|
|
|
247
250
|
|
|
251
|
+
# ── o roteiro inteiro, com entonação por parte ─────────────────────────────
|
|
252
|
+
# "A voz está sem entonação." (25/09/2026). Uma chamada só, com o texto todo,
|
|
253
|
+
# sai no mesmo ritmo e no mesmo tom do começo ao fim. Aqui cada parte do
|
|
254
|
+
# roteiro é falada com o registro dela — o gancho mais aceso, o número mais
|
|
255
|
+
# devagar, o fecho mais alto — e as partes são costuradas com um respiro. O
|
|
256
|
+
# tempo de CADA PALAVRA volta junto (WordBoundary do edge-tts): é o que
|
|
257
|
+
# sincroniza a tipografia com a voz.
|
|
258
|
+
REGISTRO = {
|
|
259
|
+
"gancho": {"ritmo": "+6%", "tom": "+3Hz", "respiro": 0.35},
|
|
260
|
+
"numero": {"ritmo": "-7%", "tom": "-1Hz", "respiro": 0.45},
|
|
261
|
+
"contraste": {"ritmo": "+2%", "tom": "+1Hz", "respiro": 0.4},
|
|
262
|
+
"lista": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.35},
|
|
263
|
+
"citacao": {"ritmo": "-10%", "tom": "-2Hz", "respiro": 0.5},
|
|
264
|
+
"cta": {"ritmo": "+4%", "tom": "+2Hz", "respiro": 0.2},
|
|
265
|
+
"normal": {"ritmo": "+0%", "tom": "+0Hz", "respiro": 0.3},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parte_edge(texto, voz, ritmo, tom, saida):
|
|
270
|
+
import asyncio
|
|
271
|
+
import edge_tts
|
|
272
|
+
|
|
273
|
+
async def rodar():
|
|
274
|
+
c = edge_tts.Communicate(texto, voz, rate=ritmo, pitch=tom, boundary="WordBoundary")
|
|
275
|
+
palavras = []
|
|
276
|
+
with open(saida, "wb") as fh:
|
|
277
|
+
async for ch in c.stream():
|
|
278
|
+
if ch["type"] == "audio":
|
|
279
|
+
fh.write(ch["data"])
|
|
280
|
+
elif ch["type"] == "WordBoundary":
|
|
281
|
+
de = ch["offset"] / 1e7
|
|
282
|
+
palavras.append({"de": round(de, 3), "ate": round(de + ch["duration"] / 1e7, 3),
|
|
283
|
+
"texto": ch["text"]})
|
|
284
|
+
return palavras
|
|
285
|
+
return asyncio.run(rodar())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _duracao(arquivo):
|
|
289
|
+
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
290
|
+
"-of", "csv=p=0", str(arquivo)], capture_output=True, text=True, timeout=30)
|
|
291
|
+
try:
|
|
292
|
+
return float(r.stdout.strip())
|
|
293
|
+
except ValueError:
|
|
294
|
+
return 0.0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def comando_roteiro(args):
|
|
298
|
+
partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
|
|
299
|
+
if not partes:
|
|
300
|
+
return {"ok": False, "error": "o roteiro veio vazio"}
|
|
301
|
+
if not edge_disponivel() and not instalar_edge():
|
|
302
|
+
return {"ok": False, "error": "a narração precisa do pacote edge-tts",
|
|
303
|
+
"comoResolver": f"{sys.executable} -m pip install edge-tts"}
|
|
304
|
+
v = voz_de(args.voz)
|
|
305
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
306
|
+
lista, trechos, palavras, t = [], [], [], 0.0
|
|
307
|
+
for i, parte in enumerate(partes):
|
|
308
|
+
texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
|
|
309
|
+
if not texto:
|
|
310
|
+
trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
|
|
311
|
+
continue
|
|
312
|
+
reg = REGISTRO.get(parte.get("registro") or "normal", REGISTRO["normal"])
|
|
313
|
+
mp3 = Path(tmp) / f"p{i:02d}.mp3"
|
|
314
|
+
try:
|
|
315
|
+
ps = _parte_edge(texto, v["voz"], reg["ritmo"], reg["tom"], mp3)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
return {"ok": False, "error": f"a voz falhou na parte {i + 1}: {str(e)[:200]}"}
|
|
318
|
+
dur = _duracao(mp3)
|
|
319
|
+
wav = Path(tmp) / f"p{i:02d}.wav"
|
|
320
|
+
# Cada parte vira wav com o respiro no fim: costurar mp3 direto
|
|
321
|
+
# deixa um clique e um silêncio de enchimento entre as partes.
|
|
322
|
+
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp3), "-af",
|
|
323
|
+
f"apad=pad_dur={reg['respiro']}", "-ar", "44100", "-ac", "1", str(wav)],
|
|
324
|
+
capture_output=True, timeout=60)
|
|
325
|
+
lista.append(wav)
|
|
326
|
+
ws = [{"de": round(t + w["de"], 3), "ate": round(t + w["ate"], 3), "texto": w["texto"]} for w in ps]
|
|
327
|
+
palavras += ws
|
|
328
|
+
trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
|
|
329
|
+
t += dur + reg["respiro"]
|
|
330
|
+
roteiro_txt = Path(tmp) / "lista.txt"
|
|
331
|
+
roteiro_txt.write_text("".join(f"file '{w}'\n" for w in lista), encoding="utf-8")
|
|
332
|
+
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
|
|
333
|
+
"-i", str(roteiro_txt), "-af", "loudnorm=I=-14:TP=-1.5:LRA=9",
|
|
334
|
+
"-c:a", "libmp3lame", "-q:a", "3", str(args.saida)],
|
|
335
|
+
capture_output=True, timeout=180)
|
|
336
|
+
if r.returncode != 0:
|
|
337
|
+
return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
|
|
338
|
+
return {"ok": True, "arquivo": str(args.saida), "motor": "edge", "voz": v["voz"],
|
|
339
|
+
"segundos": round(t, 3), "partes": trechos, "palavras": palavras}
|
|
340
|
+
|
|
341
|
+
|
|
248
342
|
def main():
|
|
249
343
|
p = argparse.ArgumentParser(description="Narração do PrimoCode Studio")
|
|
250
344
|
sub = p.add_subparsers(dest="comando", required=True)
|
|
@@ -258,11 +352,18 @@ def main():
|
|
|
258
352
|
f.add_argument("--ritmo", default="")
|
|
259
353
|
f.add_argument("--tom", default="")
|
|
260
354
|
|
|
355
|
+
r = sub.add_parser("roteiro")
|
|
356
|
+
r.add_argument("--arquivo", required=True)
|
|
357
|
+
r.add_argument("--saida", required=True)
|
|
358
|
+
r.add_argument("--voz", default="thalita")
|
|
359
|
+
|
|
261
360
|
sub.add_parser("vozes")
|
|
262
361
|
|
|
263
362
|
args = p.parse_args()
|
|
264
363
|
if args.comando == "vozes":
|
|
265
364
|
saida = {"ok": True, "vozes": VOZES, "estilos": list(ESTILOS.keys())}
|
|
365
|
+
elif args.comando == "roteiro":
|
|
366
|
+
saida = comando_roteiro(args)
|
|
266
367
|
else:
|
|
267
368
|
saida = comando_falar(args)
|
|
268
369
|
|
package/studio/rostos.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rostos.py — onde estão as pessoas em cada foto.
|
|
3
|
+
|
|
4
|
+
"Aparecem três homens ali, ela está cortada, não dá pra ver ninguém."
|
|
5
|
+
(25/09/2026)
|
|
6
|
+
|
|
7
|
+
Foto deitada num vídeo em pé perde dois terços da largura. Recortar pelo
|
|
8
|
+
centro corta as pessoas. Aqui se acha o retângulo que cobre todos os rostos
|
|
9
|
+
da foto (MediaPipe, o mesmo do recorte da pessoa), e o vídeo enquadra nele —
|
|
10
|
+
ou, quando os rostos não cabem no recorte, mostra a foto inteira.
|
|
11
|
+
|
|
12
|
+
Uso:
|
|
13
|
+
python3 rostos.py foto1.webp foto2.jpg …
|
|
14
|
+
Saída (JSON): {"ok": true, "fotos": {"foto1.webp": {"largura", "altura", "n",
|
|
15
|
+
"cx", "cy", "x0", "x1", "y0", "y1"}}} (tudo de 0 a 1)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
try:
|
|
24
|
+
import cv2
|
|
25
|
+
import mediapipe as mp
|
|
26
|
+
except Exception as e: # sem as bibliotecas, ninguém é enquadrado — e não é erro
|
|
27
|
+
print(json.dumps({"ok": False, "error": f"sem mediapipe: {e}"}))
|
|
28
|
+
return
|
|
29
|
+
det = mp.solutions.face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.45)
|
|
30
|
+
fotos = {}
|
|
31
|
+
for caminho in sys.argv[1:]:
|
|
32
|
+
img = cv2.imread(caminho)
|
|
33
|
+
if img is None:
|
|
34
|
+
continue
|
|
35
|
+
h, w = img.shape[:2]
|
|
36
|
+
r = det.process(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
|
37
|
+
caixas = []
|
|
38
|
+
for d in (r.detections or []):
|
|
39
|
+
b = d.location_data.relative_bounding_box
|
|
40
|
+
caixas.append((max(0, b.xmin), max(0, b.ymin), min(1, b.xmin + b.width), min(1, b.ymin + b.height)))
|
|
41
|
+
item = {"largura": w, "altura": h, "n": len(caixas)}
|
|
42
|
+
if caixas:
|
|
43
|
+
x0 = min(c[0] for c in caixas); y0 = min(c[1] for c in caixas)
|
|
44
|
+
x1 = max(c[2] for c in caixas); y1 = max(c[3] for c in caixas)
|
|
45
|
+
item.update({"x0": round(x0, 3), "y0": round(y0, 3), "x1": round(x1, 3), "y1": round(y1, 3),
|
|
46
|
+
"cx": round((x0 + x1) / 2, 3), "cy": round((y0 + y1) / 2, 3)})
|
|
47
|
+
fotos[caminho] = item
|
|
48
|
+
print(json.dumps({"ok": True, "fotos": fotos}))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|