primocode 10.7.0 → 10.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/bin/primocode.js +58 -37
- package/lib/boas-vindas.js +12 -13
- package/lib/config.js +5 -2
- package/lib/conta.js +194 -169
- package/lib/estudio/conferencia.js +98 -0
- package/lib/estudio/gerativo.js +25 -3
- package/lib/estudio/index.js +10 -2
- package/lib/estudio/kit/fluido.jsx +5 -0
- package/lib/estudio/kit/mundo.jsx +11 -2
- package/lib/estudio/kit/voxel.jsx +58 -15
- package/lib/estudio/personagem.js +35 -7
- package/lib/nuvem.js +11 -22
- package/lib/planos.js +71 -0
- package/package.json +2 -2
package/lib/conta.js
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* conta.js —
|
|
2
|
+
* conta.js — a conta do PrimoCode, no Firebase do próprio PrimoCode.
|
|
3
3
|
*
|
|
4
|
-
* "
|
|
5
|
-
*
|
|
4
|
+
* "não vai mais precisar conecta … conta no Primo Code … Firebase …
|
|
5
|
+
* redefinir senha … plano obrigatoriamente" (Joel, 12/10/2026)
|
|
6
6
|
*
|
|
7
|
-
* ──
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* ── O LOGIN ──────────────────────────────────────────────────────────────
|
|
8
|
+
* `/login` abre o navegador em primocode.web.app/entrar. Lá a pessoa cria a
|
|
9
|
+
* conta, entra ou redefine a senha. A página devolve o login para o terminal
|
|
10
|
+
* por um formulário enviado a http://127.0.0.1:<porta>/pronto (uma navegação,
|
|
11
|
+
* não um fetch: o navegador não barra página pública falando com o
|
|
12
|
+
* localhost por navegação). O `estado` aleatório impede outra página de
|
|
13
|
+
* empurrar um login que o terminal não pediu.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
* 2. abre um pedido de dispositivo (`/api/dispositivo/novo`);
|
|
14
|
-
* 3. aprova esse pedido com o token do Firebase e recolhe o token `pc_`.
|
|
15
|
+
* Sem navegador (SSH, servidor): `/login senha` pergunta e-mail e senha aqui.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* ── A CREDENCIAL ─────────────────────────────────────────────────────────
|
|
18
|
+
* Fica o refresh token do Firebase e o token de acesso da hora (1h). O de
|
|
19
|
+
* acesso vai ao servidor do PrimoCode no `x-primocode-token`, que confere a
|
|
20
|
+
* assinatura com as chaves do Google, sem perguntar a ninguém. Mora em
|
|
21
|
+
* `~/.primocode/conta.json` (600), NUNCA no config.json: aquele é o arquivo
|
|
22
|
+
* que alguém cola num chamado de suporte.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* esforço, mascote — e é o que alguém abre para conferir um ajuste, cola num
|
|
25
|
-
* chamado de suporte ou copia para outra máquina. Credencial junto de
|
|
26
|
-
* preferência é credencial que vaza por hábito, não por ataque.
|
|
27
|
-
*
|
|
28
|
-
* Mora em `~/.primocode/conta.json`, com permissão 600 onde o sistema tem
|
|
29
|
-
* permissão (no Windows o modo é ignorado, e está dito no comentário de lá).
|
|
24
|
+
* ── O PLANO ──────────────────────────────────────────────────────────────
|
|
25
|
+
* Mora em users/{uid} no Firestore (plano, planoAte), escrito só pelo
|
|
26
|
+
* servidor quando o pagamento cai. O terminal lê com o token da pessoa; o
|
|
27
|
+
* arquivo local guarda a última leitura só para o selo da abertura.
|
|
30
28
|
*/
|
|
31
29
|
|
|
32
30
|
'use strict';
|
|
@@ -34,67 +32,37 @@
|
|
|
34
32
|
const fs = require('fs');
|
|
35
33
|
const os = require('os');
|
|
36
34
|
const path = require('path');
|
|
37
|
-
const https = require('https');
|
|
38
35
|
const http = require('http');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// serviços diferentes: um responde o chat do app e a conta, o outro é o motor
|
|
42
|
-
// do agente. Confundir os dois dá "não autenticado" sem explicação.
|
|
43
|
-
const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api.onrender.com';
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const { spawn } = require('child_process');
|
|
44
38
|
|
|
45
39
|
const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
cabecalhos['Content-Length'] = dados.length;
|
|
73
|
-
}
|
|
74
|
-
// Quem chama pode mandar o token (o do Firebase, no login) ou `null`
|
|
75
|
-
// para ir SEM token nenhum — o pedido ao Firebase nunca pode levar o
|
|
76
|
-
// `pc_` guardado. Sem a opção, vai o da credencial guardada.
|
|
77
|
-
const cred = token !== undefined ? (token ? { token } : null) : ler();
|
|
78
|
-
if (cred && cred.token) cabecalhos.Authorization = `Bearer ${cred.token}`;
|
|
79
|
-
|
|
80
|
-
const req = lib.request({
|
|
81
|
-
hostname: url.hostname, port: url.port || undefined,
|
|
82
|
-
path: url.pathname + url.search,
|
|
83
|
-
method: dados ? 'POST' : 'GET', timeout, headers: cabecalhos,
|
|
84
|
-
}, (res) => {
|
|
85
|
-
let texto = '';
|
|
86
|
-
res.setEncoding('utf8');
|
|
87
|
-
res.on('data', (c) => { texto += c; });
|
|
88
|
-
res.on('end', () => {
|
|
89
|
-
try { resolve(JSON.parse(texto)); }
|
|
90
|
-
catch { reject(new Error(`resposta inesperada do servidor (${res.statusCode})`)); }
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
req.on('timeout', () => req.destroy(new Error('o servidor demorou demais')));
|
|
94
|
-
req.on('error', reject);
|
|
95
|
-
if (dados) req.write(dados);
|
|
96
|
-
req.end();
|
|
97
|
-
});
|
|
41
|
+
/* PÚBLICA: identifica o projeto, não dá acesso a nada sem a senha da pessoa
|
|
42
|
+
(quem decide o que cada um faz são as regras do Firestore). */
|
|
43
|
+
const FIREBASE_CHAVE = process.env.PRIMOCODE_FIREBASE_API_KEY || 'AIzaSyBrzCAbG4AilaDyiOcJI0R1il_aldOxUsg';
|
|
44
|
+
const PROJETO = process.env.PRIMOCODE_FIREBASE_PROJETO || 'primocode';
|
|
45
|
+
const IDENTIDADE = process.env.PRIMOCODE_FIREBASE_AUTH_URL || 'https://identitytoolkit.googleapis.com/v1';
|
|
46
|
+
const RENOVAR = process.env.PRIMOCODE_FIREBASE_TOKEN_URL || 'https://securetoken.googleapis.com/v1';
|
|
47
|
+
const FIRESTORE = process.env.PRIMOCODE_FIRESTORE || 'https://firestore.googleapis.com';
|
|
48
|
+
const SITE = process.env.PRIMOCODE_SITE || 'https://primocode.web.app';
|
|
49
|
+
|
|
50
|
+
/* Os planos pagos, com os nomes antigos do Conecta ainda valendo. A lista
|
|
51
|
+
de verdade é a do servidor (lib/planos.js de lá); esta só desenha o selo. */
|
|
52
|
+
const PLANOS_PAGOS = ['primo', 'pro', 'max', 'studio', 'premium', 'super', 'empresarial'];
|
|
53
|
+
const ehPago = (plano, ate) => PLANOS_PAGOS.includes(String(plano == null ? '' : plano).trim().toLowerCase())
|
|
54
|
+
&& (!ate || new Date(ate).getTime() > Date.now());
|
|
55
|
+
|
|
56
|
+
async function json(url, { metodo = 'POST', corpo, cabecalhos = {}, form = false, buscar = fetch } = {}) {
|
|
57
|
+
const op = { method: metodo, headers: { Accept: 'application/json', ...cabecalhos } };
|
|
58
|
+
if (corpo != null) {
|
|
59
|
+
op.headers['Content-Type'] = form ? 'application/x-www-form-urlencoded' : 'application/json';
|
|
60
|
+
op.body = form ? new URLSearchParams(corpo).toString() : JSON.stringify(corpo);
|
|
61
|
+
}
|
|
62
|
+
const r = await buscar(url, { ...op, signal: AbortSignal.timeout(20000) });
|
|
63
|
+
let j = {};
|
|
64
|
+
try { j = await r.json(); } catch { /* pode vir vazio */ }
|
|
65
|
+
return { ok: r.ok, status: r.status, j: j || {} };
|
|
98
66
|
}
|
|
99
67
|
|
|
100
68
|
// ── o arquivo ────────────────────────────────────────────────────────────
|
|
@@ -106,9 +74,8 @@ function ler() {
|
|
|
106
74
|
|
|
107
75
|
function gravar(dados) {
|
|
108
76
|
fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
|
|
109
|
-
fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), 'utf8');
|
|
110
|
-
//
|
|
111
|
-
// arquivos e a chamada não falha — lá quem protege é a pasta do perfil.
|
|
77
|
+
fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
78
|
+
// No Windows o modo é ignorado e a chamada não falha: lá protege a pasta do perfil.
|
|
112
79
|
try { fs.chmodSync(ARQUIVO, 0o600); } catch {}
|
|
113
80
|
}
|
|
114
81
|
|
|
@@ -117,127 +84,185 @@ function sair() {
|
|
|
117
84
|
catch { return false; }
|
|
118
85
|
}
|
|
119
86
|
|
|
120
|
-
/** Quem está conectado, sem ir à rede. */
|
|
87
|
+
/** Quem está conectado, sem ir à rede. A credencial antiga do Conecta (`pc_`) não vale mais. */
|
|
121
88
|
function atual() {
|
|
122
89
|
const c = ler();
|
|
123
|
-
if (!c || !c.
|
|
124
|
-
return { uid: c.uid, plano: c.plano || 'free',
|
|
125
|
-
pro: ehPago(c.plano), desde: c.desde };
|
|
90
|
+
if (!c || !c.refreshToken || !c.uid) return null;
|
|
91
|
+
return { uid: c.uid, email: c.email || null, plano: c.plano || 'free', planoAte: c.planoAte || null,
|
|
92
|
+
pro: ehPago(c.plano, c.planoAte), desde: c.desde };
|
|
126
93
|
}
|
|
127
94
|
|
|
128
|
-
|
|
95
|
+
/** Sobrou a credencial do Conecta? Então a pessoa precisa entrar de novo, e vale dizer por quê. */
|
|
96
|
+
function doConectaAntigo() {
|
|
97
|
+
const c = ler();
|
|
98
|
+
return !!(c && c.token && !c.refreshToken);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── os tokens ────────────────────────────────────────────────────────────
|
|
129
102
|
|
|
130
103
|
/* O que o Firebase responde quando não entra → o que a pessoa lê. Os códigos
|
|
131
104
|
vêm no começo de `error.message` ("INVALID_PASSWORD : …"). */
|
|
132
105
|
function erroDoFirebase(codigo) {
|
|
133
106
|
const c = String(codigo || '').split(/\s|:/)[0].toUpperCase();
|
|
134
107
|
if (/INVALID_LOGIN_CREDENTIALS|INVALID_PASSWORD|EMAIL_NOT_FOUND/.test(c)) {
|
|
135
|
-
return
|
|
108
|
+
return `e-mail ou senha incorretos. Esqueceu a senha? ${SITE}/entrar#esqueci`;
|
|
136
109
|
}
|
|
137
110
|
if (c === 'INVALID_EMAIL') return 'esse e-mail não parece válido.';
|
|
138
111
|
if (c === 'USER_DISABLED') return 'essa conta está desativada.';
|
|
139
112
|
if (/TOO_MANY_ATTEMPTS/.test(c)) return 'muitas tentativas seguidas. Espere alguns minutos e tente de novo.';
|
|
140
113
|
if (c === 'MISSING_PASSWORD') return 'faltou a senha.';
|
|
114
|
+
if (/TOKEN_EXPIRED|INVALID_REFRESH_TOKEN|USER_NOT_FOUND/.test(c)) return 'a sessão venceu. Entre de novo com /login.';
|
|
141
115
|
return `o login recusou (${c || 'sem motivo'}).`;
|
|
142
116
|
}
|
|
143
117
|
|
|
118
|
+
/** Guarda um login novo (vindo do navegador ou da senha) e já lê o plano. */
|
|
119
|
+
async function guardarLogin({ refreshToken, idToken, uid, email, expiraEm }, op = {}) {
|
|
120
|
+
gravar({ uid, email: email || null, refreshToken, idToken: idToken || null,
|
|
121
|
+
expira: idToken ? Date.now() + (Number(expiraEm) || 3600) * 1000 : 0,
|
|
122
|
+
plano: 'free', planoAte: null, desde: new Date().toISOString() });
|
|
123
|
+
const p = await lerPlano(op);
|
|
124
|
+
const plano = p.ok ? p.plano : 'free';
|
|
125
|
+
return { ok: true, uid, email, plano, pro: ehPago(plano, p.planoAte) };
|
|
126
|
+
}
|
|
127
|
+
|
|
144
128
|
/**
|
|
145
|
-
* O
|
|
146
|
-
*
|
|
147
|
-
* @param {object} op
|
|
148
|
-
* @param {string} op.email
|
|
149
|
-
* @param {string} op.senha
|
|
129
|
+
* O token de acesso válido por pelo menos mais 5 minutos (renova se precisar).
|
|
130
|
+
* Devolve null se não há conta, e apaga a credencial se o Firebase disser que ela morreu.
|
|
150
131
|
*/
|
|
151
|
-
async function
|
|
152
|
-
|
|
153
|
-
if (!
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
let fb;
|
|
132
|
+
async function tokenValido({ buscar = fetch, forcar = false } = {}) {
|
|
133
|
+
const c = ler();
|
|
134
|
+
if (!c || !c.refreshToken) return null;
|
|
135
|
+
if (!forcar && c.idToken && Date.now() < (c.expira || 0) - 300000) return c.idToken;
|
|
136
|
+
let r;
|
|
157
137
|
try {
|
|
158
|
-
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return { ok: false, error: erroDoFirebase(fb && fb.error && fb.error.message) };
|
|
138
|
+
r = await json(`${RENOVAR}/token?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
|
|
139
|
+
{ corpo: { grant_type: 'refresh_token', refresh_token: c.refreshToken }, form: true, buscar });
|
|
140
|
+
} catch { return c.idToken || null; } // sem rede: o servidor responde se ainda vale
|
|
141
|
+
if (!r.ok || !r.j.id_token) {
|
|
142
|
+
const m = r.j.error && (r.j.error.message || r.j.error);
|
|
143
|
+
if (/TOKEN_EXPIRED|INVALID_REFRESH_TOKEN|USER_NOT_FOUND|USER_DISABLED/.test(String(m))) sair();
|
|
144
|
+
return null;
|
|
166
145
|
}
|
|
146
|
+
gravar({ ...c, idToken: r.j.id_token, refreshToken: r.j.refresh_token || c.refreshToken,
|
|
147
|
+
expira: Date.now() + (Number(r.j.expires_in) || 3600) * 1000 });
|
|
148
|
+
return r.j.id_token;
|
|
149
|
+
}
|
|
167
150
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!pedido || !pedido.success) {
|
|
177
|
-
return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
|
|
151
|
+
let relogio = null;
|
|
152
|
+
/* Na abertura e a cada minuto: o token dura uma hora, e o config lê o do
|
|
153
|
+
arquivo a cada pedido. Depois do notebook dormir, no máximo um minuto de atraso. */
|
|
154
|
+
async function manterVivo() {
|
|
155
|
+
await tokenValido().catch(() => null);
|
|
156
|
+
if (!relogio) {
|
|
157
|
+
relogio = setInterval(() => { tokenValido().catch(() => null); }, 60000);
|
|
158
|
+
relogio.unref();
|
|
178
159
|
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── o plano ──────────────────────────────────────────────────────────────
|
|
179
163
|
|
|
180
|
-
|
|
181
|
-
|
|
164
|
+
async function lerPlano({ buscar = fetch } = {}) {
|
|
165
|
+
const token = await tokenValido({ buscar });
|
|
166
|
+
const c = ler();
|
|
167
|
+
if (!token || !c) return { ok: false, error: 'não conectado' };
|
|
168
|
+
let r;
|
|
182
169
|
try {
|
|
183
|
-
|
|
184
|
-
{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
gravar({
|
|
195
|
-
token: r.token, uid: r.uid, plano: r.plano || 'free', email,
|
|
196
|
-
desde: new Date().toISOString(), servidor: CONECTA,
|
|
197
|
-
});
|
|
198
|
-
return { ok: true, uid: r.uid, plano: r.plano, pro: ehPago(r.plano) };
|
|
199
|
-
} catch (e) {
|
|
200
|
-
return { ok: false, error: `não consegui concluir o login: ${e.message}` };
|
|
201
|
-
}
|
|
170
|
+
r = await json(`${FIRESTORE}/v1/projects/${PROJETO}/databases/(default)/documents/users/${encodeURIComponent(c.uid)}`,
|
|
171
|
+
{ metodo: 'GET', cabecalhos: { Authorization: `Bearer ${token}` }, buscar });
|
|
172
|
+
} catch (e) { return { ok: false, error: e.message, offline: true }; }
|
|
173
|
+
if (r.status === 404) return { ok: true, plano: 'free', planoAte: null }; // conta nova, sem documento ainda
|
|
174
|
+
if (!r.ok) return { ok: false, error: `não consegui ler o plano (HTTP ${r.status})` };
|
|
175
|
+
const f = r.j.fields || {};
|
|
176
|
+
const plano = (f.plano && f.plano.stringValue) || 'free';
|
|
177
|
+
const planoAte = (f.planoAte && f.planoAte.timestampValue) || null;
|
|
178
|
+
if (plano !== c.plano || planoAte !== c.planoAte) gravar({ ...ler(), plano, planoAte });
|
|
179
|
+
return { ok: true, plano, planoAte };
|
|
202
180
|
}
|
|
203
181
|
|
|
204
182
|
/**
|
|
205
|
-
* Confirma o plano
|
|
206
|
-
*
|
|
207
|
-
* O plano guardado no arquivo é uma fotografia do dia do login: quem assinou
|
|
208
|
-
* ontem continuaria como gratuito, e quem cancelou continuaria Premium. Quem
|
|
209
|
-
* sabe é o servidor, e é ele quem diz — o arquivo local serve para mostrar o
|
|
210
|
-
* selo na abertura sem esperar a rede, e só.
|
|
183
|
+
* Confirma o plano e devolve a sessão (o uid e o token) para o Firestore.
|
|
184
|
+
* O plano guardado é a foto do último login; quem sabe é o documento.
|
|
211
185
|
*/
|
|
212
|
-
async function sessao() {
|
|
186
|
+
async function sessao(op = {}) {
|
|
213
187
|
const c = ler();
|
|
214
|
-
if (!c || !c.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Token revogado no painel, ou vencido: o arquivo local não vale mais.
|
|
220
|
-
// Apagar aqui evita a pessoa ficar vendo "conectado" para sempre com
|
|
221
|
-
// uma credencial morta.
|
|
222
|
-
if (r && /autenticado/i.test(r.error || '')) sair();
|
|
223
|
-
return { ok: false, error: (r && r.error) || 'o servidor recusou a sessão' };
|
|
188
|
+
if (!c || !c.refreshToken) return { ok: false, error: 'não conectado' };
|
|
189
|
+
const p = await lerPlano(op);
|
|
190
|
+
if (!p.ok) {
|
|
191
|
+
if (!ler()) return { ok: false, error: 'a sessão venceu. Entre de novo com /login.' };
|
|
192
|
+
return { ok: false, error: p.error, offline: !!p.offline };
|
|
224
193
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
194
|
+
const agora = ler();
|
|
195
|
+
return { ok: true, uid: agora.uid, email: agora.email, plano: p.plano, planoAte: p.planoAte,
|
|
196
|
+
pro: ehPago(p.plano, p.planoAte), idToken: agora.idToken, projeto: PROJETO };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── entrar ───────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Sem navegador: e-mail e senha aqui mesmo. */
|
|
202
|
+
async function entrar({ email, senha } = {}, op = {}) {
|
|
203
|
+
email = String(email || '').trim();
|
|
204
|
+
if (!email || !senha) return { ok: false, error: 'informe o e-mail e a senha.' };
|
|
205
|
+
let r;
|
|
206
|
+
try {
|
|
207
|
+
r = await json(`${IDENTIDADE}/accounts:signInWithPassword?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
|
|
208
|
+
{ corpo: { email, password: String(senha), returnSecureToken: true }, buscar: op.buscar });
|
|
209
|
+
} catch (e) { return { ok: false, error: `não consegui falar com o login: ${e.message}` }; }
|
|
210
|
+
if (!r.ok || !r.j.idToken) return { ok: false, error: erroDoFirebase(r.j.error && r.j.error.message) };
|
|
211
|
+
return guardarLogin({ refreshToken: r.j.refreshToken, idToken: r.j.idToken, uid: r.j.localId,
|
|
212
|
+
email: r.j.email || email, expiraEm: r.j.expiresIn }, op);
|
|
230
213
|
}
|
|
231
214
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
215
|
+
function abrirNoNavegador(url) {
|
|
216
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
217
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
218
|
+
: ['xdg-open', [url]];
|
|
219
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); return true; }
|
|
220
|
+
catch { return false; }
|
|
237
221
|
}
|
|
238
222
|
|
|
239
|
-
|
|
240
|
-
|
|
223
|
+
const PAGINA = (titulo, texto) => `<!doctype html><meta charset="utf-8"><title>PrimoCode</title><link rel="icon" href="data:,">
|
|
224
|
+
<style>body{font:16px system-ui;background:#0b0b10;color:#eee;display:grid;place-items:center;height:100vh;margin:0}
|
|
225
|
+
main{text-align:center;max-width:28rem;padding:1rem}h1{font-size:1.4rem}</style>
|
|
226
|
+
<main><h1>${titulo}</h1><p>${texto}</p></main>`;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Pelo navegador: abre o site, espera o formulário voltar no localhost.
|
|
230
|
+
* @param {{abrir?:(url:string)=>any, aviso?:(url:string)=>void, prazo?:number, buscar?:Function}} op
|
|
231
|
+
*/
|
|
232
|
+
function entrarPeloNavegador({ abrir = abrirNoNavegador, aviso = () => {}, prazo = 10 * 60000, buscar } = {}) {
|
|
233
|
+
const estado = crypto.randomBytes(16).toString('hex');
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
let acabou = false;
|
|
236
|
+
const fim = (r) => { if (acabou) return; acabou = true; clearTimeout(timer); srv.close(); resolve(r); };
|
|
237
|
+
const srv = http.createServer((req, res) => {
|
|
238
|
+
if (req.method !== 'POST' || !req.url.startsWith('/pronto')) { res.statusCode = 404; return res.end(); }
|
|
239
|
+
let corpo = '';
|
|
240
|
+
req.on('data', (c) => { corpo += c; if (corpo.length > 20000) req.destroy(); });
|
|
241
|
+
req.on('end', async () => {
|
|
242
|
+
const f = new URLSearchParams(corpo);
|
|
243
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
244
|
+
if (f.get('estado') !== estado || !f.get('refreshToken') || !f.get('uid')) {
|
|
245
|
+
res.statusCode = 400;
|
|
246
|
+
return res.end(PAGINA('Esse login não é deste terminal', 'Volte ao terminal e rode /login de novo.'));
|
|
247
|
+
}
|
|
248
|
+
res.end(PAGINA('Pronto, você entrou ✓', 'Pode fechar esta aba e voltar ao terminal.'));
|
|
249
|
+
try {
|
|
250
|
+
fim(await guardarLogin({ refreshToken: f.get('refreshToken'), idToken: f.get('idToken'),
|
|
251
|
+
uid: f.get('uid'), email: f.get('email'), expiraEm: 3600 }, { buscar }));
|
|
252
|
+
} catch (e) { fim({ ok: false, error: e.message }); }
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
const timer = setTimeout(() => fim({ ok: false, error: 'o login no navegador não voltou em 10 minutos. Rode /login de novo.' }), prazo);
|
|
256
|
+
srv.on('error', (e) => fim({ ok: false, error: `não consegui abrir a porta do login: ${e.message}` }));
|
|
257
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
258
|
+
const url = `${SITE}/entrar?porta=${srv.address().port}&estado=${estado}`;
|
|
259
|
+
aviso(url);
|
|
260
|
+
abrir(url);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
241
263
|
}
|
|
242
264
|
|
|
243
|
-
module.exports = {
|
|
265
|
+
module.exports = {
|
|
266
|
+
entrar, entrarPeloNavegador, sair, atual, sessao, lerPlano, tokenValido, manterVivo, doConectaAntigo,
|
|
267
|
+
erroDoFirebase, ehPago, ARQUIVO, SITE, PROJETO, FIREBASE_CHAVE, PLANOS_PAGOS,
|
|
268
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conferencia.js — a conferência do vídeo PRONTO: o arquivo, medido, e não
|
|
3
|
+
* o plano.
|
|
4
|
+
*
|
|
5
|
+
* "gostei, ficou bom. mas ainda não. tá quase lá" (Joel, 11/10)
|
|
6
|
+
*
|
|
7
|
+
* O revisor.js olha o plano antes do render. Este olha o .mp4 que saiu, e
|
|
8
|
+
* troca o "quase lá" no olho por número que se confere sem abrir o vídeo
|
|
9
|
+
* (a ideia veio do review.mjs do cth9191/animate, MIT):
|
|
10
|
+
*
|
|
11
|
+
* som volume integrado (LUFS), variação (LRA) e pico verdadeiro, pelo
|
|
12
|
+
* ebur128 do ffmpeg. Alvo: -14 LUFS (±2) e pico até -1 dBTP.
|
|
13
|
+
* parado trecho em que a imagem não muda por 2 s ou mais, pelo
|
|
14
|
+
* freezedetect, com o quadro reduzido a 320 px. Calibrado em 13/10
|
|
15
|
+
* em 5 vídeos: na resolução cheia o ruído do fundo esconde a tela
|
|
16
|
+
* parada; a 160 px o texto fica pequeno demais e a palavra que
|
|
17
|
+
* entra não conta como mudança. A 320 px o teste-10-5-motion-com-
|
|
18
|
+
* imagem dá 6,5 s a 8,3 s ("torrado" parado, a pílula vazia, e
|
|
19
|
+
* para quando "café" entra), e os vídeos 3D não dão nada.
|
|
20
|
+
*
|
|
21
|
+
* Só mede e avisa. Não conserta nada: quem decide se refaz é o agente.
|
|
22
|
+
* As duas leituras (lerSom, lerParados) são puras e testáveis sem vídeo.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const video = require('./video.js');
|
|
28
|
+
|
|
29
|
+
const ALVO = { lufs: -14, folgaLufs: 2, picoMax: -1, paradoMin: 2 };
|
|
30
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
31
|
+
|
|
32
|
+
/* O ebur128 escreve uma linha a cada 100 ms e um resumo no fim. Só o resumo
|
|
33
|
+
vale: a última linha "I:" de antes dele é a média até ali, não do vídeo. */
|
|
34
|
+
function lerSom(texto) {
|
|
35
|
+
const t = String(texto || '');
|
|
36
|
+
const i = t.lastIndexOf('Summary:');
|
|
37
|
+
if (i < 0) return null;
|
|
38
|
+
const resumo = t.slice(i);
|
|
39
|
+
const num = (re) => { const m = resumo.match(re); return m ? Number(m[1]) : null; };
|
|
40
|
+
const lufs = num(/I:\s*(-?[\d.]+|-inf)\s*LUFS/);
|
|
41
|
+
if (lufs == null) return null;
|
|
42
|
+
return { lufs, lra: num(/LRA:\s*(-?[\d.]+)\s*LU\b/), pico: num(/Peak:\s*(-?[\d.]+)\s*dBFS/) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* O freezedetect marca o começo e o fim de cada trecho parado. Um começo
|
|
46
|
+
sem fim é tela parada até o último quadro. Dois trechos colados (o texto
|
|
47
|
+
some e o fundo fica) são, na tela, uma parada só. */
|
|
48
|
+
function lerParados(texto, duracao) {
|
|
49
|
+
const trechos = [];
|
|
50
|
+
const juntar = (de, ate) => {
|
|
51
|
+
const ultimo = trechos[trechos.length - 1];
|
|
52
|
+
if (ultimo && de - ultimo.ate < 0.2) ultimo.ate = r1(ate);
|
|
53
|
+
else trechos.push({ de: r1(de), ate: r1(ate) });
|
|
54
|
+
};
|
|
55
|
+
let de = null;
|
|
56
|
+
for (const linha of String(texto || '').split('\n')) {
|
|
57
|
+
const i = linha.match(/freeze_start:\s*([\d.]+)/);
|
|
58
|
+
if (i) { de = Number(i[1]); continue; }
|
|
59
|
+
const f = linha.match(/freeze_end:\s*([\d.]+)/);
|
|
60
|
+
if (f && de != null) { juntar(de, Number(f[1])); de = null; }
|
|
61
|
+
}
|
|
62
|
+
if (de != null && duracao > de) juntar(de, duracao);
|
|
63
|
+
return trechos;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** As medidas viram avisos. Puro. */
|
|
67
|
+
function julgar({ som, parados = [] }, alvo = ALVO) {
|
|
68
|
+
const avisos = [];
|
|
69
|
+
if (som && Number.isFinite(som.lufs)) {
|
|
70
|
+
if (Math.abs(som.lufs - alvo.lufs) > alvo.folgaLufs) {
|
|
71
|
+
avisos.push(`volume em ${som.lufs} LUFS, longe do alvo de ${alvo.lufs} (${som.lufs < alvo.lufs ? 'baixo demais' : 'alto demais'})`);
|
|
72
|
+
}
|
|
73
|
+
if (som.pico != null && som.pico > alvo.picoMax) avisos.push(`pico de ${som.pico} dBTP: estoura no celular (o máximo é ${alvo.picoMax})`);
|
|
74
|
+
}
|
|
75
|
+
for (const p of parados) {
|
|
76
|
+
const dura = r1(p.ate - p.de);
|
|
77
|
+
if (dura >= alvo.paradoMin) avisos.push(`imagem parada por ${dura} s (de ${p.de} s a ${p.ate} s)`);
|
|
78
|
+
}
|
|
79
|
+
return avisos;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Mede o arquivo numa passada só do ffmpeg e devolve medidas e avisos. */
|
|
83
|
+
async function conferir(arquivo, { duracao = 0 } = {}) {
|
|
84
|
+
const c = video.conferir();
|
|
85
|
+
if (!c.ok) return c;
|
|
86
|
+
const r = await video.rodar(video.ffmpeg(), [
|
|
87
|
+
'-hide_banner', '-nostats', '-i', arquivo,
|
|
88
|
+
'-vf', 'scale=320:-2,freezedetect=n=0.002:d=1.5',
|
|
89
|
+
'-af', 'ebur128=peak=true',
|
|
90
|
+
'-f', 'null', '-',
|
|
91
|
+
], { timeout: 10 * 60000 });
|
|
92
|
+
if (!r.ok) return { ok: false, error: `a conferência não leu o vídeo: ${r.erro.slice(-200)}` };
|
|
93
|
+
const som = lerSom(r.erro);
|
|
94
|
+
const parados = lerParados(r.erro, duracao);
|
|
95
|
+
return { ok: true, som, parados, avisos: julgar({ som, parados }) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { ALVO, lerSom, lerParados, julgar, conferir };
|
package/lib/estudio/gerativo.js
CHANGED
|
@@ -486,8 +486,17 @@ const itensDe = (p) => [...(p.itens || []), ...(p.barras || []).map((b) => `${b.
|
|
|
486
486
|
...(p.cores || []).map((c) => (typeof c === 'string' ? c : `${c.nome || ''} ${c.hex || ''}`.trim()))]
|
|
487
487
|
.map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
|
|
488
488
|
|
|
489
|
+
/* A parte que só traz `fala` (o modelo grátis escreve assim) não tinha nada
|
|
490
|
+
para a tela, e cada composição quebrava num lugar diferente (12/10). O
|
|
491
|
+
título vira a primeira oração da fala, com até 6 palavras. */
|
|
492
|
+
function tituloDaFala(fala) {
|
|
493
|
+
const oracao = String(fala || '').split(/[.?!;:—]|,\s/).map((x) => x.trim()).find(Boolean) || '';
|
|
494
|
+
const w = oracao.split(/\s+/).filter(Boolean);
|
|
495
|
+
return w.length > 6 ? w.slice(0, 6).join(' ') + '…' : oracao + (/\?/.test(String(fala || '').slice(oracao.length, oracao.length + 2)) ? '?' : '');
|
|
496
|
+
}
|
|
497
|
+
|
|
489
498
|
function conteudoDe(p, marca = {}) {
|
|
490
|
-
const titulo = p.titulo || p.texto ||
|
|
499
|
+
const titulo = p.titulo || p.texto || tituloDaFala(p.fala);
|
|
491
500
|
const itens = itensDe(p);
|
|
492
501
|
if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
|
|
493
502
|
if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
|
|
@@ -1072,6 +1081,14 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
|
|
|
1072
1081
|
});
|
|
1073
1082
|
return;
|
|
1074
1083
|
}
|
|
1084
|
+
if (nome === 'duelo' && !(String(c.nao || '').trim() && String(c.sim || '').trim())) {
|
|
1085
|
+
/* O "contraste" sem nao/sim (o roteiro só trouxe a fala, 12/10) virava
|
|
1086
|
+
dois títulos vazios e o plano morria. Vira um título com o que a
|
|
1087
|
+
voz diz no começo do trecho. */
|
|
1088
|
+
const ditas = (ctx.palavras || []).filter((w) => w.de >= br.de - 0.05 && w.de < br.ate).slice(0, 5).map((w) => w.texto).join(' ');
|
|
1089
|
+
registra(compor(anterior === 'gigante' ? 'pilha' : 'gigante', { ...br, conteudo: { tipo: 'titulo', titulo: c.titulo || c.texto || ditas || '…', sub: '', destaque: c.destaque || '' } }, ctx));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1075
1092
|
if (nome === 'duelo') {
|
|
1076
1093
|
const meio = casar([c.sim], ctx.palavras, { de: br.de + 1.2 })[0];
|
|
1077
1094
|
const corte = r3(Math.min(br.ate - 1.4, Math.max(br.de + 1.3, meio != null ? meio - 0.1 : (br.de + br.ate) / 2)));
|
|
@@ -1483,7 +1500,8 @@ function compor(comp, br, ctx) {
|
|
|
1483
1500
|
comportamento: o.comportamento || null, riscado: !!o.riscado, envelhece: null,
|
|
1484
1501
|
};
|
|
1485
1502
|
l.palavras = l.texto.split(/\s+/).filter(Boolean).map((w) => ({ texto: w, papel: ehTese(w) ? 'tese' : funcao(w) ? 'funcao' : 'normal' }));
|
|
1486
|
-
|
|
1503
|
+
// Linha sem palavra não entra na tela: todo o resto (acento, golpe, tempo) supõe ao menos uma (12/10).
|
|
1504
|
+
if (l.palavras.length) b.linhas.push(l);
|
|
1487
1505
|
return l;
|
|
1488
1506
|
};
|
|
1489
1507
|
// Os pesos DENTRO da frase: artigo leve, verbo regular, tese pesada e maior.
|
|
@@ -1520,6 +1538,10 @@ function compor(comp, br, ctx) {
|
|
|
1520
1538
|
if (!destaqueTk.length) l.palavras.forEach((p) => { p.papel = 'normal'; });
|
|
1521
1539
|
}
|
|
1522
1540
|
const ultima = b.linhas[b.linhas.length - 1];
|
|
1541
|
+
/* Sem texto que caiba (a parte veio só com fala, sem título), a tela
|
|
1542
|
+
fica sem linha — e o acento caía em undefined.palavras e matava o
|
|
1543
|
+
plano inteiro (12/10, o estilo padrão quebrava). Sem linha, sem acento. */
|
|
1544
|
+
if (!ultima) break;
|
|
1523
1545
|
if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: ultima.id } });
|
|
1524
1546
|
else if (/[.!]$/.test(txt) || s.chance(0.4)) b.acento.push({ forma: 'ponto', alvo: { linha: ultima.id, fim: true } });
|
|
1525
1547
|
else {
|
|
@@ -1859,7 +1881,7 @@ function contarAcentos(b) {
|
|
|
1859
1881
|
return n;
|
|
1860
1882
|
}
|
|
1861
1883
|
|
|
1862
|
-
module.exports = {
|
|
1884
|
+
module.exports = { tituloDaFala,
|
|
1863
1885
|
montarGerativo, partituraDe, sonsDoVideo, musicaDe, desenharSom, humorDe, escolherVisual, MOLDURAS, AMBIENTES, ACENTOS_JEITO, ARRANJOS_ICONE, RITMOS_ICONE, MATERIAIS_ICONE, corGenerica, BANIDAS, HUMORES, REGRAS, VARIAVEIS, COMPOSICOES, COMPORTAMENTOS, SAIDAS, ACENTOS, FPS,
|
|
1864
1886
|
especEixos, sorteador, hash, larguraEm, conteudoDe, casar, HEROIS_OBJETO, HEROIS_CAMPO, HEROIS_3D, FAMILIA_HEROI,
|
|
1865
1887
|
};
|