primocode 10.0.9 → 10.1.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.
@@ -4,8 +4,9 @@
4
4
  * (30/09), com "sem referências": nada de foto, então o produto é modelado
5
5
  * aqui, com as peças do three.js, e iluminado como em estúdio.
6
6
  *
7
- * {"tipo":"kit","peca":"produto3d","modelo":"fone"|"celular"|"lata"|"caixa",
7
+ * {"tipo":"kit","peca":"produto3d","modelo":"fone"|"celular"|"lata"|"caixa"|"notebook"|"garrafa"|"relogio",
8
8
  * "cor":"#1b1c20","corAcento":"#ff5a1f","t":0,"giro":0.35,
9
+ * "acabamento":"metal","camera":"alto","luz":"recorte","ritmo":"pendulo", (variacao3d.js)
9
10
  * "x":0,"y":0,"largura":1920,"altura":1080}
10
11
  *
11
12
  * O movimento: entra pequeno e girado e assenta com mola (0,9 s), depois gira
@@ -33,19 +34,34 @@ function Ambiente({ forca = 1 }) {
33
34
 
34
35
  const saida = (x) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 3);
35
36
 
36
- function Material({ cor, brilho = 0.35, metal = 0.25, verniz = 0.8, emissivo }) {
37
- return <meshPhysicalMaterial color={cor} roughness={brilho} metalness={metal} clearcoat={verniz} clearcoatRoughness={0.25}
37
+ function Material({ cor, brilho = 0.35, metal = 0.25, verniz = 0.8, iris = 0, emissivo }) {
38
+ return <meshPhysicalMaterial color={cor} roughness={brilho} metalness={metal} clearcoat={verniz} clearcoatRoughness={0.25} iridescence={iris}
38
39
  {...(emissivo ? { emissive: emissivo, emissiveIntensity: 1.2 } : {})} />;
39
40
  }
40
41
 
41
42
  // O fone: o arco, os deslizadores, as conchas com o anel de acento, as almofadas e o logo aceso.
42
- function Fone({ cor, acento }) {
43
+ /* AS VARIAÇÕES (variacao3d.js sorteia os nomes): o acabamento do corpo, o
44
+ enquadramento, a luz e o ritmo. Sem nome, o de sempre. */
45
+ const ACABAMENTOS = {
46
+ verniz: { brilho: 0.3, metal: 0.3, verniz: 0.8 }, metal: { brilho: 0.18, metal: 0.92, verniz: 0.3 },
47
+ fosco: { brilho: 0.78, metal: 0, verniz: 0 }, perola: { brilho: 0.25, metal: 0.1, verniz: 1, iris: 1 },
48
+ acetinado: { brilho: 0.5, metal: 0.45, verniz: 0.4 },
49
+ };
50
+ const CAMERAS = {
51
+ frente: {}, alto: { y: 1.5, inclina: 0.12 }, baixo: { y: -0.6, inclina: -0.1 },
52
+ perfil: { giro0: -1.7, roll: 0.07 }, perto: { fov: 25 },
53
+ };
54
+ // [chave, preenchimento, recorte na cor de acento, ambiente]
55
+ const LUZES = { estudio: [3.2, 1.1, 3.2, 0.35], recorte: [1.6, 0.4, 5.5, 0.15], suave: [2.2, 2.0, 1.4, 0.7], lateral: [4.2, 0.2, 2.6, 0.2] };
56
+ const RITMOS = { lento: 0.18, medio: 0.35, rapido: 0.6 };
57
+
58
+ function Fone({ cor, acento, fin }) {
43
59
  const R = 1.05;
44
60
  const concha = (lado) => (
45
61
  <group position={[lado * R, -0.12, 0]}>
46
62
  <mesh rotation={[0, 0, Math.PI / 2]}>
47
63
  <cylinderGeometry args={[0.44, 0.46, 0.26, 64]} />
48
- <Material cor={cor} brilho={0.3} metal={0.35} />
64
+ <Material cor={cor} brilho={0.3} metal={0.35} {...fin} />
49
65
  </mesh>
50
66
  <mesh position={[lado * 0.135, 0, 0]} rotation={[0, Math.PI / 2, 0]}>
51
67
  <torusGeometry args={[0.43, 0.035, 24, 96]} />
@@ -65,7 +81,7 @@ function Fone({ cor, acento }) {
65
81
  <group>
66
82
  <mesh>
67
83
  <torusGeometry args={[R, 0.075, 32, 160, Math.PI]} />
68
- <Material cor={cor} brilho={0.28} metal={0.4} />
84
+ <Material cor={cor} brilho={0.28} metal={0.4} {...fin} />
69
85
  </mesh>
70
86
  <mesh position={[0, -0.06, 0]} scale={[1, 1, 1.6]}>
71
87
  <torusGeometry args={[R - 0.07, 0.05, 24, 140, Math.PI]} />
@@ -83,10 +99,10 @@ function Fone({ cor, acento }) {
83
99
  );
84
100
  }
85
101
 
86
- function Celular({ cor, acento }) {
102
+ function Celular({ cor, acento, fin }) {
87
103
  return (
88
104
  <group rotation={[0, 0, 0.05]}>
89
- <mesh><boxGeometry args={[1.0, 2.05, 0.1]} /><Material cor={cor} brilho={0.22} metal={0.7} /></mesh>
105
+ <mesh><boxGeometry args={[1.0, 2.05, 0.1]} /><Material cor={cor} brilho={0.22} metal={0.7} {...fin} /></mesh>
90
106
  <mesh position={[0, 0, 0.052]}><planeGeometry args={[0.92, 1.96]} /><Material cor="#0b0c10" brilho={0.1} metal={0} emissivo="#0d1830" /></mesh>
91
107
  <mesh position={[0, 0.3, 0.054]}><circleGeometry args={[0.22, 48]} /><Material cor={acento} emissivo={acento} /></mesh>
92
108
  <mesh position={[0.26, 0.78, -0.07]} rotation={[Math.PI / 2, 0, 0]}><cylinderGeometry args={[0.14, 0.14, 0.05, 48]} /><Material cor="#222" brilho={0.1} metal={0.9} /></mesh>
@@ -94,26 +110,72 @@ function Celular({ cor, acento }) {
94
110
  );
95
111
  }
96
112
 
97
- function Lata({ cor, acento }) {
113
+ function Lata({ cor, acento, fin }) {
98
114
  return (
99
115
  <group>
100
- <mesh><cylinderGeometry args={[0.55, 0.55, 1.9, 96]} /><Material cor={cor} brilho={0.2} metal={0.85} /></mesh>
116
+ <mesh><cylinderGeometry args={[0.55, 0.55, 1.9, 96]} /><Material cor={cor} brilho={0.2} metal={0.85} {...fin} /></mesh>
101
117
  <mesh position={[0, 0.2, 0]}><cylinderGeometry args={[0.556, 0.556, 0.5, 96, 1, true]} /><Material cor={acento} brilho={0.3} metal={0.3} /></mesh>
102
118
  <mesh position={[0, 0.97, 0]}><cylinderGeometry args={[0.5, 0.55, 0.06, 96]} /><Material cor="#c9ccd2" brilho={0.25} metal={1} /></mesh>
103
119
  </group>
104
120
  );
105
121
  }
106
122
 
107
- function Caixa({ cor, acento }) {
123
+ function Caixa({ cor, acento, fin }) {
108
124
  return (
109
125
  <group>
110
- <mesh><boxGeometry args={[1.5, 1.5, 1.5]} /><Material cor={cor} brilho={0.3} metal={0.3} /></mesh>
126
+ <mesh><boxGeometry args={[1.5, 1.5, 1.5]} /><Material cor={cor} brilho={0.3} metal={0.3} {...fin} /></mesh>
111
127
  <mesh position={[0, 0, 0.752]}><planeGeometry args={[1.0, 0.18]} /><Material cor={acento} emissivo={acento} /></mesh>
112
128
  </group>
113
129
  );
114
130
  }
115
131
 
116
- const MODELOS = { fone: Fone, headset: Fone, celular: Celular, phone: Celular, lata: Lata, garrafa: Lata, caixa: Caixa };
132
+ // O notebook: a base, o teclado escuro, a tampa aberta a ~105° e a tela acesa.
133
+ function Notebook({ cor, acento, fin }) {
134
+ return (
135
+ <group position={[0, -0.3, 0.1]} rotation={[0.1, 0, 0]} scale={[0.7, 0.7, 0.7]}>{/* a 1,0 a tela saía cortada no topo (05/10) */}
136
+ <mesh><boxGeometry args={[2.2, 0.08, 1.45]} /><Material cor={cor} brilho={0.25} metal={0.75} {...fin} /></mesh>
137
+ <mesh position={[0, 0.042, -0.12]}><boxGeometry args={[1.9, 0.01, 0.75]} /><Material cor="#141416" brilho={0.8} metal={0} verniz={0} /></mesh>
138
+ <mesh position={[0, 0.042, 0.45]}><boxGeometry args={[0.7, 0.01, 0.38]} /><Material cor="#26272c" brilho={0.4} metal={0.2} /></mesh>
139
+ <group position={[0, 0.04, -0.72]} rotation={[-0.26, 0, 0]}>
140
+ <mesh position={[0, 0.72, 0]}><boxGeometry args={[2.2, 1.44, 0.06]} /><Material cor={cor} brilho={0.25} metal={0.75} {...fin} /></mesh>
141
+ <mesh position={[0, 0.74, 0.032]}><planeGeometry args={[2.0, 1.25]} /><Material cor="#0b0c10" brilho={0.1} metal={0} emissivo="#0d1830" /></mesh>
142
+ <mesh position={[0, 0.74, 0.034]}><circleGeometry args={[0.2, 48]} /><Material cor={acento} emissivo={acento} /></mesh>
143
+ </group>
144
+ </group>
145
+ );
146
+ }
147
+
148
+ // A garrafa: o corpo, o ombro, o gargalo, a tampa e o rótulo na cor de acento.
149
+ function Garrafa({ cor, acento, fin }) {
150
+ return (
151
+ <group position={[0, -0.15, 0]}>
152
+ <mesh><cylinderGeometry args={[0.5, 0.5, 1.5, 96]} /><Material cor={cor} brilho={0.15} metal={0.6} {...fin} /></mesh>
153
+ <mesh position={[0, 0.95, 0]}><cylinderGeometry args={[0.2, 0.5, 0.4, 96]} /><Material cor={cor} brilho={0.15} metal={0.6} {...fin} /></mesh>
154
+ <mesh position={[0, 1.3, 0]}><cylinderGeometry args={[0.2, 0.2, 0.3, 64]} /><Material cor={cor} brilho={0.15} metal={0.6} {...fin} /></mesh>
155
+ <mesh position={[0, 1.52, 0]}><cylinderGeometry args={[0.23, 0.23, 0.18, 64]} /><Material cor={acento} brilho={0.3} metal={0.8} /></mesh>
156
+ <mesh position={[0, 0.05, 0]}><cylinderGeometry args={[0.506, 0.506, 0.55, 96, 1, true]} /><Material cor={acento} brilho={0.5} metal={0.1} verniz={0.3} /></mesh>
157
+ </group>
158
+ );
159
+ }
160
+
161
+ // O relógio: a caixa redonda, o aro de acento, o mostrador aceso e a pulseira.
162
+ function Relogio({ cor, acento, fin }) {
163
+ return (
164
+ <group>
165
+ <mesh rotation={[Math.PI / 2, 0, 0]}><cylinderGeometry args={[0.62, 0.62, 0.22, 96]} /><Material cor={cor} brilho={0.2} metal={0.85} {...fin} /></mesh>
166
+ <mesh position={[0, 0, 0.11]}><torusGeometry args={[0.6, 0.045, 24, 96]} /><Material cor={acento} brilho={0.15} metal={0.95} verniz={1} /></mesh>
167
+ <mesh position={[0, 0, 0.112]}><circleGeometry args={[0.55, 64]} /><Material cor="#0b0c10" brilho={0.1} metal={0} emissivo="#0d1830" /></mesh>
168
+ <mesh position={[0, 0.16, 0.116]}><boxGeometry args={[0.04, 0.32, 0.01]} /><Material cor={acento} emissivo={acento} /></mesh>
169
+ <mesh position={[0.1, 0, 0.118]} rotation={[0, 0, -Math.PI / 2]}><boxGeometry args={[0.05, 0.22, 0.01]} /><Material cor="#f4f4f6" emissivo="#9aa0aa" /></mesh>
170
+ <mesh position={[0.66, 0, 0]} rotation={[Math.PI / 2, 0, 0]}><cylinderGeometry args={[0.06, 0.06, 0.14, 24]} /><Material cor={cor} brilho={0.2} metal={0.85} {...fin} /></mesh>
171
+ {[-1, 1].map((l) => <mesh key={l} position={[0, l * 1.0, -0.02]}><boxGeometry args={[0.6, 0.85, 0.08]} /><Material cor="#1a1a1d" brilho={0.7} metal={0} verniz={0.2} /></mesh>)}
172
+ </group>
173
+ );
174
+ }
175
+
176
+ const MODELOS = { fone: Fone, headset: Fone, celular: Celular, phone: Celular, lata: Lata, garrafa: Garrafa, caixa: Caixa,
177
+ notebook: Notebook, laptop: Notebook, relogio: Relogio, watch: Relogio };
178
+ export const MODELOS_3D = Object.keys(MODELOS);
117
179
 
118
180
  export function Produto3D({ el, t }) {
119
181
  const W = el.largura || 1920, H = el.altura || 1080;
@@ -123,21 +185,26 @@ export function Produto3D({ el, t }) {
123
185
  const Modelo = MODELOS[el.modelo] || Caixa;
124
186
  const cor = el.cor || '#1b1c20';
125
187
  const acento = el.corAcento || '#ff5a1f';
126
- const giro = (el.giro0 ?? -0.9) + (1 - k) * 1.4 + tl * (el.giro ?? 0.35);
188
+ const cam = CAMERAS[el.camera] || {};
189
+ const luz = LUZES[el.luz] || LUZES.estudio;
190
+ const fin = ACABAMENTOS[el.acabamento];
191
+ // O ritmo: o giro contínuo em três velocidades, ou o pêndulo, que vai e volta mostrando a frente.
192
+ const anda = el.ritmo === 'pendulo' ? Math.sin(tl * 0.9) * 0.7 : tl * (RITMOS[el.ritmo] ?? el.giro ?? 0.35);
193
+ const giro = (el.giro0 ?? cam.giro0 ?? -0.9) + (1 - k) * 1.4 + anda;
127
194
  const escala = (el.escala || 1) * (0.72 + 0.28 * k);
128
195
  const flutua = Math.sin(tl * 1.3) * 0.05;
129
196
  return (
130
197
  <div style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: W, height: H, opacity: t >= t0 ? Math.min(1, tl / 0.25) : 0 }}>
131
- <ThreeCanvas width={W} height={H} camera={{ position: [0, 0.35, 4.4], fov: el.fov || 32 }} gl={{ antialias: true, preserveDrawingBuffer: true }}>
198
+ <ThreeCanvas width={W} height={H} camera={{ position: [0, cam.y ?? 0.35, 4.4], fov: el.fov || cam.fov || 32 }} gl={{ antialias: true, preserveDrawingBuffer: true }}>
132
199
  <Ambiente forca={0.8} />
133
200
  <hemisphereLight args={['#ffffff', '#202020', 0.5]} />
134
- <ambientLight intensity={0.35} />
135
- <directionalLight position={[3, 4, 5]} intensity={3.2} />
136
- <directionalLight position={[-2, 3, 4]} intensity={1.1} />
137
- <directionalLight position={[-4, 1.5, -3]} intensity={3.2} color={acento} />
201
+ <ambientLight intensity={luz[3]} />
202
+ <directionalLight position={el.luz === 'lateral' ? [5, 1.5, 1.5] : [3, 4, 5]} intensity={luz[0]} />
203
+ <directionalLight position={[-2, 3, 4]} intensity={luz[1]} />
204
+ <directionalLight position={[-4, 1.5, -3]} intensity={luz[2]} color={acento} />
138
205
  <pointLight position={[0, -2, 3]} intensity={0.8} />
139
- <group position={[0, flutua - 0.05, 0]} rotation={[0.18 + (1 - k) * 0.3, giro, 0]} scale={[escala, escala, escala]}>
140
- <Modelo cor={cor} acento={acento} />
206
+ <group position={[0, flutua - 0.05, 0]} rotation={[0.18 + (cam.inclina || 0) + (1 - k) * 0.3, giro, cam.roll || 0]} scale={[escala, escala, escala]}>
207
+ <Modelo cor={cor} acento={acento} fin={fin} />
141
208
  </group>
142
209
  {/* O chão. O gerativo dispensa (semChao): o disco virava uma faixa escura atrás do texto. */}
143
210
  {!el.semChao && (
@@ -55,9 +55,11 @@ const PECAS = {
55
55
  icone3d: { precisa: ['icone'] },
56
56
  // O motion gerativo (kit/gerativo.jsx): a partitura inteira, numa peça só (gerativo.js).
57
57
  gerativo: { precisa: ['partitura'] },
58
+ // O estilo fluido (kit/fluido.jsx): a forma que vira a próxima, numa peça só (fluido.js).
59
+ fluido: { precisa: ['partitura'] },
58
60
  };
59
61
 
60
- const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'index.jsx'];
62
+ const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'index.jsx'];
61
63
 
62
64
  /** Os arquivos do kit, para o projeto Remotion: [{nome: 'kit/base.jsx', conteudo}]. */
63
65
  function arquivos() {
@@ -0,0 +1,111 @@
1
+ /* A VOZ LOCAL (Kokoro-82M): roda na CPU desta máquina, sem cota e sem
2
+ servidor. Entra quando o Gemini falha (cota, voz cortada) ou quando o
3
+ roteiro pede `voz: 'kokoro'` (ou PRIMOCODE_VOZ=kokoro). Sem `uv` ou sem
4
+ espeak-ng ela diz que não está disponível e a corrente segue — nunca
5
+ derruba a narração.
6
+
7
+ Provado nesta máquina em 05/10: 11 s de áudio em 3,5 s (+4,3 s de carga
8
+ do modelo), sem o corte no fim que o Gemini às vezes deixa. */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { spawn, execFileSync } = require('child_process');
12
+
13
+ const SCRIPT = path.join(__dirname, '..', '..', 'studio', 'voz_kokoro.py');
14
+ // Sem `transformers>=4.45` o uv escolhe a 4.12 e o tokenizers não compila (05/10).
15
+ const DEPS = ['kokoro>=0.9.4', 'transformers>=4.45', 'soundfile', 'misaki[en]'];
16
+ // As vozes do Studio viram as do Kokoro em português: pf_dora (feminina), pm_alex (masculina).
17
+ const VOZES = { thalita: 'pf_dora', francisca: 'pf_dora', feminina: 'pf_dora', antonio: 'pm_alex', masculina: 'pm_alex' };
18
+
19
+ /** O roteiro (ou quem usa) pediu a voz local? */
20
+ function querKokoro(voz, env = process.env) {
21
+ return String(voz || '').toLowerCase() === 'kokoro' || String(env.PRIMOCODE_VOZ || '').toLowerCase() === 'kokoro';
22
+ }
23
+
24
+ /** "PrimoCode" sai "Primo Kodi" no Kokoro; escrito assim, sai certo. */
25
+ function pronuncia(texto) {
26
+ return String(texto || '').replace(/primo\s*code/gi, 'Primo Côude');
27
+ }
28
+
29
+ function vozKokoro(voz) {
30
+ const v = String(voz || '').toLowerCase();
31
+ if (/^p[fm]_/.test(v)) return v;
32
+ return VOZES[v] || 'pm_alex';
33
+ }
34
+
35
+ /** Onde está o espeak-ng (o Kokoro precisa dele para o português). */
36
+ function acharEspeak({ existe = fs.existsSync, brew = () => execFileSync('brew', ['--prefix', 'espeak-ng'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim() } = {}) {
37
+ const bases = [];
38
+ try { bases.push(brew()); } catch { /* sem brew */ }
39
+ bases.push('/opt/homebrew/opt/espeak-ng', '/usr/local/opt/espeak-ng', '/usr');
40
+ for (const b of bases) {
41
+ if (!b) continue;
42
+ const dados = path.join(b, 'share', 'espeak-ng-data');
43
+ const lib = ['lib/libespeak-ng.dylib', 'lib/libespeak-ng.so.1', 'lib/x86_64-linux-gnu/libespeak-ng.so.1', 'lib/aarch64-linux-gnu/libespeak-ng.so.1']
44
+ .map((l) => path.join(b, l)).find(existe);
45
+ if (existe(dados) && lib) return { dados, lib };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** O comando inteiro (uv + deps fixadas + env do espeak), ou null se falta peça. */
51
+ function comando({ uv, espeak, arquivo, saida, voz, env = process.env }) {
52
+ if (!uv || !espeak) return null;
53
+ const args = ['run', '--quiet', '--python', '3.11'];
54
+ for (const d of DEPS) args.push('--with', d);
55
+ args.push('python', SCRIPT, 'roteiro', '--arquivo', arquivo, '--saida', saida, '--voz', vozKokoro(voz));
56
+ return { cmd: uv, args, env: { ...env, ESPEAK_DATA_PATH: espeak.dados, PHONEMIZER_ESPEAK_LIBRARY: espeak.lib } };
57
+ }
58
+
59
+ function rodar({ cmd, args, env }, timeout) {
60
+ return new Promise((resolve) => {
61
+ const p = spawn(cmd, args, { env });
62
+ let saida = '', erro = '';
63
+ p.stdout.on('data', (d) => { saida += d.toString(); });
64
+ p.stderr.on('data', (d) => { erro += d.toString(); });
65
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
66
+ p.on('close', () => {
67
+ clearTimeout(relogio);
68
+ try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
69
+ catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o kokoro não respondeu' }); }
70
+ });
71
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
72
+ });
73
+ }
74
+
75
+ /** Narra a lista [{texto}] em mp3 com o Kokoro. Devolve o mesmo formato do narrar.py. */
76
+ async function narrar(lista, mp3, { voz, uv, espeak = acharEspeak(), executar = rodar, timeout = 15 * 60000 } = {}) {
77
+ const arquivo = mp3.replace(/\.mp3$/, '') + '.kokoro.json';
78
+ const c = comando({ uv, espeak, arquivo, saida: mp3, voz });
79
+ if (!c) return { ok: false, indisponivel: true, error: !uv ? 'kokoro: falta o uv' : 'kokoro: falta o espeak-ng (brew install espeak-ng)' };
80
+ try {
81
+ fs.writeFileSync(arquivo, JSON.stringify(lista.map((x) => ({ ...x, texto: pronuncia(x.texto) }))));
82
+ return await executar(c, timeout);
83
+ } catch (e) {
84
+ return { ok: false, error: e.message };
85
+ } finally {
86
+ try { fs.unlinkSync(arquivo); } catch { /* fica */ }
87
+ }
88
+ }
89
+
90
+ /** A ordem das vozes: pedida → Kokoro e depois o Edge; senão Gemini → Kokoro → Edge.
91
+ `motorVoz: 'edge'` no roteiro pula as outras. */
92
+ function ordem(voz, env = process.env, { soEdge = false } = {}) {
93
+ if (soEdge) return ['edge'];
94
+ return querKokoro(voz, env) ? ['kokoro', 'edge'] : ['gemini', 'kokoro', 'edge'];
95
+ }
96
+
97
+ /** Roda a corrente: a primeira voz que der certo ganha; a falha de uma nunca derruba. */
98
+ async function corrente(passos, provedores) {
99
+ let ultimo = { ok: false, error: 'nenhuma voz disponível' };
100
+ for (const nome of passos) {
101
+ const f = provedores[nome];
102
+ if (!f) continue;
103
+ let r;
104
+ try { r = await f(); } catch (e) { r = { ok: false, error: e.message }; }
105
+ if (r && r.ok) return r;
106
+ if (r) ultimo = r;
107
+ }
108
+ return ultimo;
109
+ }
110
+
111
+ module.exports = { querKokoro, pronuncia, vozKokoro, acharEspeak, comando, narrar, ordem, corrente, DEPS, SCRIPT };
@@ -29,6 +29,7 @@ const { caber } = require('./composicao.js');
29
29
  const { misturarCor, ehCor } = require('./morfar.js');
30
30
  const { tempoDasPartes, gatilhos, volumesDaTrilha, r2 } = require('./ritmo.js');
31
31
  const { tese, termoDef } = require('./vox.js');
32
+ const { modeloDoPedido } = require('./variacao3d.js');
32
33
 
33
34
  const FPS = 30;
34
35
  const k = (t, v, e) => (e ? { t: r2(t), v, e } : { t: r2(t), v });
@@ -328,9 +329,7 @@ function montarLancamento({ roteiro, variante = 'lancamento', largura = 1920, al
328
329
  // ── as peças que qualquer parte pode pedir pelo nome ("visual") ────
329
330
  /* Qual modelo 3D: o que a parte ou o roteiro disser, ou o que o texto
330
331
  deixa claro. Sem pista, a caixa — um produto genérico que ainda gira. */
331
- const modeloDe = (txt) => (/fone|headset|headphone|auricular/i.test(txt) ? 'fone'
332
- : /celular|smartphone|iphone|phone|telefone/i.test(txt) ? 'celular'
333
- : /lata|bebida|refri|cerveja|energ[eé]tico|garrafa/i.test(txt) ? 'lata' : 'caixa');
332
+ const modeloDe = modeloDoPedido;
334
333
  const VISUAL = {
335
334
  /* O PRODUTO EM 3D (kit/tres.jsx): modelado e iluminado aqui, sem foto
336
335
  de fora — girando devagar no centro, o nome embaixo. */
@@ -340,7 +339,7 @@ function montarLancamento({ roteiro, variante = 'lancamento', largura = 1920, al
340
339
  const f = fit(p.titulo || nome, W - 2 * M, 1, deitado ? 84 : 96);
341
340
  const yT = deitado ? H * 0.8 : H * 0.76;
342
341
  const els = [
343
- { tipo: 'kit', peca: 'produto3d', modelo, cor: p.cor3d || roteiro.cor3d || '#1b1c20', corAcento: ACENTO, t: 0, giro: 0.4,
342
+ { tipo: 'kit', peca: 'produto3d', modelo, cor: p.cor3d || roteiro.cor3d || '#1b1c20', corAcento: ACENTO, t: 0, giro: 0.4, ...(roteiro.variacao3d || {}),
344
343
  x: 0, y: r2(deitado ? -H * 0.02 : -H * 0.08), largura: W, altura: H, escala: deitado ? 0.8 : 1.15 }, // deitado a 1,0 o arco saía cortado no topo
345
344
  { tipo: 'kit', peca: 'manchete', modo: 'desfoque', texto: p.titulo || nome, x: W / 2, y: r2(yT), largura: W - 2 * M, alinhamento: 'center',
346
345
  tamanho: f.tamanho, peso: 800, cor: '#f4f4f6', t: Math.min(0.9, gatilhos([p.titulo || nome], tp, t0, d)[0] || 0.6), ...base },
@@ -0,0 +1,20 @@
1
+ /**
2
+ * legibilidade.js — a lei da legibilidade do /brag, em número (comum/desmontados/
3
+ * latent-spaces-brag.md): rótulo curto fica PARADO ~0,8 s; frase, 0,3 s por
4
+ * palavra; "fast-in, then hold — never fast-in, then gone".
5
+ *
6
+ * "Parado" é o tempo entre o fim da entrada e o começo da saída. Quando a cena
7
+ * é curta, quem encolhe é a animação (entrada e saída), nunca a leitura.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const nPalavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
13
+
14
+ /** Quanto tempo o texto precisa ficar parado na tela: max(0,8 s; 0,3 s por palavra). */
15
+ const minimoLeitura = (t) => Math.max(0.8, 0.3 * nPalavras(t));
16
+
17
+ /** O que sobra para a animação depois da leitura, entre um piso e o tempo cheio. */
18
+ const sobra = (vida, leitura, piso, cheio) => Math.min(cheio, Math.max(piso, vida - leitura));
19
+
20
+ module.exports = { minimoLeitura, nPalavras, sobra };
@@ -279,14 +279,20 @@ function Elemento({ el, t, dados }) {
279
279
  if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
280
280
  const palavras = el.texto.split(' ');
281
281
  conteudo = palavras.map((w, i) => {
282
- const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
283
- const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
282
+ const marcada = !!(el.destaques && el.destaques.includes(i));
283
+ /* A PALAVRA DE DESTAQUE: com "fonteDestaque", o acento é a serifa em
284
+ itálico (na cor do texto), que chega um instante depois das outras.
285
+ Um acento só — nada de caixa de cor atrás (a "cara de IA", 05/10). */
286
+ const serifa = marcada && el.fonteDestaque;
287
+ const t0 = (el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0) + (serifa ? 0.12 : 0);
288
+ const k = Math.min(1, Math.max(0, (tLocal - t0) / (serifa ? 0.45 : 0.28)));
284
289
  const e = CURVAS.saida(k);
285
290
  // O espaço fica FORA do span: é ali que a linha pode quebrar.
286
291
  return (
287
292
  <React.Fragment key={i}>
288
293
  <span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
289
- color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
294
+ color: marcada && !serifa ? (el.corDestaque || el.cor) : undefined,
295
+ ...(serifa ? { fontFamily: '"' + el.fonteDestaque + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, fontSize: '1.1em', letterSpacing: 0 } : {}) }}>{w}</span>
290
296
  {i < palavras.length - 1 ? ' ' : null}
291
297
  </React.Fragment>
292
298
  );
@@ -465,12 +471,14 @@ function Elemento({ el, t, dados }) {
465
471
  display: 'inline-block',
466
472
  // A palavra forte é MAIOR na fonte, não na escala: escala não
467
473
  // abre espaço, e ela cresceria por cima da vizinha.
468
- fontSize: p.forte ? '1.22em' : undefined,
474
+ fontSize: p.forte ? (e.fonteForte ? '1.1em' : '1.22em') : undefined,
475
+ // Com "fonteForte", a palavra forte é a serifa em itálico, não o letreiro maior.
476
+ ...(p.forte && e.fonteForte ? { fontFamily: '"' + e.fonteForte + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, textTransform: 'none' } : {}),
469
477
  transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
470
478
  opacity: entra,
471
479
  color: cor,
472
480
  textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
473
- WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
481
+ WebkitTextStroke: p.forte && e.fonteForte ? undefined : (e.contorno || '2px rgba(0,0,0,.55)'),
474
482
  paintOrder: 'stroke fill',
475
483
  background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
476
484
  borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
@@ -1005,4 +1013,41 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
1005
1013
  });
1006
1014
  }
1007
1015
 
1008
- module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG };
1016
+ /* ── ABRIR PARA EDITAR ────────────────────────────────────────────────
1017
+ "Quando eu falar pra editar, edita no editor de vídeo open source." O
1018
+ projeto já é Remotion: editar é abrir o Remotion Studio na pasta dele. Ele
1019
+ sobe solto (o PrimoCode não espera) e abre o navegador sozinho. */
1020
+ function comandoStudio(raiz) {
1021
+ return { cmd: 'npx', args: ['remotion', 'studio'], cwd: raiz };
1022
+ }
1023
+
1024
+ function abrirStudio({ raiz, spawnar = spawn } = {}) {
1025
+ if (!raiz || !fs.existsSync(path.join(raiz, 'package.json'))) {
1026
+ return { ok: false, error: 'não achei o projeto Remotion nesta pasta.' };
1027
+ }
1028
+ // O Studio precisa do Remotion instalado. A base compartilhada já vem do primeiro render.
1029
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1030
+ const comp = process.platform === 'win32' ? null : baseCompartilhada(raiz);
1031
+ if (comp && comp.pronta) {
1032
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* segue */ }
1033
+ }
1034
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1035
+ return { ok: false, error: 'o Remotion ainda não está instalado para este projeto.',
1036
+ comoResolver: 'renderize uma vez com estudio_render (instala o Remotion), depois abra o editor.' };
1037
+ }
1038
+ }
1039
+ const c = comandoStudio(raiz);
1040
+ const win = process.platform === 'win32';
1041
+ try {
1042
+ const p = spawnar(c.cmd, win ? c.args.map(citarWin) : c.args,
1043
+ { cwd: c.cwd, detached: true, stdio: 'ignore', shell: win });
1044
+ if (p && p.on) p.on('error', () => { /* sem npx: o caminho vai na resposta */ });
1045
+ if (p && p.unref) p.unref();
1046
+ } catch (e) {
1047
+ return { ok: false, error: e.message };
1048
+ }
1049
+ return { ok: true, raiz, comando: `cd "${raiz}" && npx remotion studio` };
1050
+ }
1051
+
1052
+ module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG,
1053
+ comandoStudio, abrirStudio };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * variacao3d.js — o produto em 3D (kit/tres.jsx) diferente a cada vídeo.
3
+ *
4
+ * "várias opções de elementos 3D... vários estilos... que não sejam padrão
5
+ * da IA" (Joel, 05/10)
6
+ *
7
+ * Antes, todo fone saía igual: o mesmo material, a mesma câmera, a mesma luz
8
+ * e o mesmo giro. Agora cada vídeo sorteia o acabamento, o enquadramento, a
9
+ * luz e o ritmo — sem repetir os últimos (memória '3d' do variedade.js). Os
10
+ * nomes daqui são as chaves que o tres.jsx desenha.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const variedade = require('./variedade.js');
16
+
17
+ const VARIACOES = {
18
+ acabamento: ['verniz', 'metal', 'fosco', 'perola', 'acetinado'],
19
+ camera: ['frente', 'alto', 'baixo', 'perfil', 'perto'],
20
+ luz: ['estudio', 'recorte', 'suave', 'lateral'],
21
+ ritmo: ['lento', 'medio', 'rapido', 'pendulo'],
22
+ };
23
+
24
+ /** Um 3D por vídeo: cada faceta sorteada sem repetir as últimas. */
25
+ function escolherVariacao3d(aleatorio = Math.random) {
26
+ const v = {};
27
+ for (const [faceta, opcoes] of Object.entries(VARIACOES)) v[faceta] = variedade.sortear(opcoes, '3d', { prefixo: faceta + ':', aleatorio });
28
+ return v;
29
+ }
30
+
31
+ /** Qual modelo o pedido descreve. Sem pista, a caixa — um produto genérico que ainda gira. */
32
+ const MODELOS3D = [
33
+ ['fone', /fone|headset|headphone|auricular/i],
34
+ ['celular', /celular|smartphone|iphone|phone|telefone/i],
35
+ ['notebook', /notebook|laptop|computador|macbook/i],
36
+ ['relogio', /rel[oó]gio|smartwatch|\bwatch\b/i],
37
+ ['garrafa', /garrafa|squeeze|bottle|garrafinha|vinho|perfume/i],
38
+ ['lata', /lata|bebida|refri|cerveja|energ[eé]tico/i],
39
+ ];
40
+
41
+ function modeloDoPedido(texto) {
42
+ const achado = MODELOS3D.find(([, re]) => re.test(String(texto || '')));
43
+ return achado ? achado[0] : 'caixa';
44
+ }
45
+
46
+ module.exports = { VARIACOES, MODELOS3D, escolherVariacao3d, modeloDoPedido };
@@ -21,7 +21,7 @@ const os = require('os');
21
21
  const path = require('path');
22
22
 
23
23
  const PASTA = path.join(os.homedir(), '.primocode', 'studio');
24
- const MEMORIA = { trilha: 16, sfx: 40 };
24
+ const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24 };
25
25
 
26
26
  function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
27
27
 
@@ -54,6 +54,22 @@ function ordenar(itens, tipo, aleatorio = Math.random) {
54
54
  return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
55
55
  }
56
56
 
57
+ /**
58
+ * Uma das opções, sem repetir as últimas: as nunca usadas primeiro (sorteadas),
59
+ * depois a usada há mais tempo. Marca a escolhida na memória do tipo. Com
60
+ * `prefixo`, várias listas dividem o mesmo arquivo ('acabamento:fosco').
61
+ */
62
+ function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random } = {}) {
63
+ if (!opcoes || !opcoes.length) return null;
64
+ const ja = usadas(tipo);
65
+ const idade = (o) => ja.indexOf(prefixo + o); // 0 = a última usada
66
+ const novas = embaralhar(opcoes.filter((o) => idade(o) < 0), aleatorio);
67
+ const velhas = opcoes.filter((o) => idade(o) >= 0).sort((a, b) => idade(b) - idade(a));
68
+ const escolha = [...novas, ...velhas][0];
69
+ marcar(tipo, prefixo + escolha);
70
+ return escolha;
71
+ }
72
+
57
73
  // ── a trilha: termos por clima ──────────────────────────────────────────
58
74
  const CLIMAS = {
59
75
  tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
@@ -102,4 +118,66 @@ function termosSfx(nome, aleatorio = Math.random) {
102
118
  return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
103
119
  }
104
120
 
105
- module.exports = { usadas, marcar, embaralhar, ordenar, climaDe, termosTrilha, termosSfx, CLIMAS, SFX };
121
+ // ── o b-roll: o mesmo pedido, de jeitos diferentes ───────────────────────
122
+ /* "não ficar só nas mesmas fontes, mesmos estilos" (Joel, 05/10): o mesmo
123
+ roteiro renderizado duas vezes trocou fonte, cor e música, mas o b-roll
124
+ veio IDÊNTICO — as mesmas mãos no teclado. O termo era o mesmo e o
125
+ primeiro resultado do Pexels também. Agora o termo ganha um jeito de
126
+ filmar sorteado, e o clipe já usado vai para o fim da fila. */
127
+ const JEITOS = ['cinematic', 'slow motion', 'close up', 'natural light', 'detail shot', 'wide shot', 'handheld'];
128
+
129
+ function termosBroll(termo, aleatorio = Math.random) {
130
+ const t = String(termo || '').replace(/\s+/g, ' ').trim();
131
+ if (!t) return [];
132
+ const jeitos = embaralhar(JEITOS.filter((j) => !t.toLowerCase().includes(j)), aleatorio).slice(0, 2);
133
+ return embaralhar([t, ...jeitos.map((j) => `${t} ${j}`)], aleatorio);
134
+ }
135
+
136
+ /**
137
+ * Busca com as variações do termo e devolve os itens na ordem de tentar:
138
+ * para no primeiro termo que trouxer algo nunca usado. `buscar(termo)`
139
+ * devolve a lista de itens ({url, …}) — é a busca de verdade, ou um stub.
140
+ */
141
+ async function buscarSemRepetir(buscar, termo, tipo = 'broll', aleatorio = Math.random) {
142
+ const ja = new Set(usadas(tipo));
143
+ let reserva = [];
144
+ for (const t of termosBroll(termo, aleatorio)) {
145
+ let itens = [];
146
+ try { itens = ordenar(await buscar(t), tipo, aleatorio); } catch { continue; }
147
+ if (itens.length && !ja.has(itens[0].url)) return itens;
148
+ if (!reserva.length) reserva = itens;
149
+ }
150
+ return reserva;
151
+ }
152
+
153
+ // ── os ícones: a coleção muda de um vídeo para o outro ──────────────────
154
+ /* O ícone automático (edicao.iconeDe) é sempre "fluent-3d:<emoji>". Estas
155
+ coleções do Iconify usam os mesmos nomes de emoji, então dá para trocar a
156
+ coleção inteira do vídeo; o que não existir na coleção nova fica no 3D. */
157
+ const FAMILIAS_ICONE = ['fluent-3d', 'fluent-emoji-flat', 'fluent-emoji', 'noto', 'twemoji', 'openmoji'];
158
+
159
+ function colecaoIcones(aleatorio = Math.random) {
160
+ return ordenar(FAMILIAS_ICONE.map((url) => ({ url })), 'icones', aleatorio)[0].url;
161
+ }
162
+
163
+ function cadaIcone(plano, fn) {
164
+ for (const cena of (plano && plano.cenas) || []) for (const el of cena.elementos || []) if (el && el.tipo === 'icone' && el.nome) fn(el);
165
+ }
166
+
167
+ /** Os ícones automáticos do plano — os que o roteiro pediu pelo nome ficam como estão. */
168
+ function iconesTrocaveis(plano, explicitos = []) {
169
+ const fixos = new Set(explicitos);
170
+ const nomes = new Set();
171
+ cadaIcone(plano, (el) => { if (/^fluent-3d:/.test(el.nome) && !fixos.has(el.nome)) nomes.add(el.nome); });
172
+ return [...nomes];
173
+ }
174
+
175
+ /** Troca no plano cada nome que estiver em `troca` ({velho: novo}). Devolve quantos trocou. */
176
+ function trocarIcones(plano, troca = {}) {
177
+ let n = 0;
178
+ cadaIcone(plano, (el) => { if (troca[el.nome]) { el.nome = troca[el.nome]; n++; } });
179
+ return n;
180
+ }
181
+
182
+ module.exports = { usadas, marcar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
183
+ colecaoIcones, iconesTrocaveis, trocarIcones, CLIMAS, SFX, JEITOS, FAMILIAS_ICONE };