primocode 10.0.9 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
@@ -658,8 +658,8 @@ function Fundo({ P, b, prevB, t, cam, frame, origem }) {
658
658
  return <div key={'q' + i} style={{ position: 'absolute', left: x, top: y, width: q.r * 2 * sc, height: q.r * 2 * sc, borderRadius: '50%',
659
659
  background: i % 5 === 0 ? b.cores.acento : b.cores.texto, opacity: (0.1 + q.z * 0.25), filter: q.z < 0.3 ? 'blur(1.5px)' : undefined }} />;
660
660
  })}
661
- <div style={{ position: 'absolute', inset: 0, ...textura, backgroundSize: passo + 'px ' + passo + 'px', backgroundPosition: px + 'px ' + py + 'px',
662
- WebkitMaskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)', maskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)' }} />
661
+ {f.textura && <div style={{ position: 'absolute', inset: 0, ...textura, backgroundSize: passo + 'px ' + passo + 'px', backgroundPosition: px + 'px ' + py + 'px',
662
+ WebkitMaskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)', maskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)' }} />}
663
663
  </div>
664
664
  );
665
665
  }
@@ -688,7 +688,7 @@ function Heroi({ b, t, P, slot }) {
688
688
  const sem = h.semente;
689
689
  const cor = h.cor, cor2 = h.cor2;
690
690
  const { cx, cy, R, fraco } = slot;
691
- const op = fraco ? 0.32 : 1;
691
+ const op = slot.op != null ? slot.op : fraco ? 0.32 : 1;
692
692
  const els = [];
693
693
  if (h.tipo === 'explosao') {
694
694
  // A explosão radial: nasce de um ponto, os raios saem em mola e giram devagar; anéis de régua em volta.
@@ -857,11 +857,21 @@ export function Gerativo({ el, t, fps }) {
857
857
  <Fundo P={P} b={b} prevB={bs[bi - 1]} t={t} cam={cam} frame={frame} origem={origem} />
858
858
  <div style={{ position: 'absolute', inset: 0, perspective: Math.round(Math.max(W, H) * 0.85) + 'px', perspectiveOrigin: '50% 48%' }}>
859
859
  <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: '0 0', transform: mundo, transformStyle: 'preserve-3d' }}>
860
+ {/* O CAMPO (o padrão que cobre o quadro) mora ATRÁS de tudo — do produto 3D
861
+ e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
862
+ {noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
863
+ const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
864
+ return (
865
+ <div key={'campo' + x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transform: 'translateZ(-200px)', opacity: some }}>
866
+ <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />
867
+ </div>
868
+ );
869
+ })}
860
870
  {produtos.map((q, i) => {
861
871
  const sai = c01((t - q.ate) / 0.3), entra = ed(c01((t - q.de) / 0.6));
862
872
  return (
863
873
  <div key={'p' + i} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: 1 - sai, transform: 'scale(' + (0.94 + 0.06 * entra + 0.1 * sai) + ')', transformOrigin: (q.x + q.largura / 2) + 'px ' + (q.y + q.altura / 2) + 'px' }}>
864
- <Produto3D el={{ x: q.x, y: q.y, largura: q.largura, altura: q.altura, modelo: q.modelo, cor: q.cor, corAcento: q.corAcento, t: q.de + 0.05, giro: 0.45, escala: q.escala || 1, semChao: true }} t={t} />
874
+ <Produto3D el={{ x: q.x, y: q.y, largura: q.largura, altura: q.altura, modelo: q.modelo, cor: q.cor, corAcento: q.corAcento, acabamento: q.acabamento, camera: q.camera, luz: q.luz, ritmo: q.ritmo, t: q.de + 0.05, giro: 0.45, escala: q.escala || 1, semChao: true }} t={t} />
865
875
  </div>
866
876
  );
867
877
  })}
@@ -898,7 +908,7 @@ export function Gerativo({ el, t, fps }) {
898
908
  {x.heroi && (() => {
899
909
  // O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
900
910
  const bl = lay.bloco, M = P.margem;
901
- if (x.heroi.modo === 'campo') return <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32 }} />;
911
+ if (x.heroi.modo === 'campo') return null; // desenhado atrás, antes do produto
902
912
  const R = Math.min(W, H) * (W >= H ? 0.2 : 0.17);
903
913
  let slot = null;
904
914
  if (W - M - bl.x1 >= R * 2.3) slot = { cx: bl.x1 + (W - M - bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R };
@@ -8,6 +8,7 @@ import { MARCA } from './marca';
8
8
  import { Papel } from './base';
9
9
  import { TRES } from './tres';
10
10
  import { GERATIVO } from './gerativo';
11
+ import { FLUIDO } from './fluido';
11
12
 
12
- export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, papel: Papel };
13
+ export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, papel: Papel };
13
14
  export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
@@ -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 };
@@ -18,14 +18,17 @@
18
18
 
19
19
  'use strict';
20
20
 
21
- let ATUAL = { modo: null, escopo: '' };
21
+ let ATUAL = { modo: null, escopo: '', explicito: false };
22
22
 
23
- function definir(modo, escopo = '') {
24
- ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim() };
23
+ function definir(modo, escopo = '', explicito = true) {
24
+ ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim(), explicito: !!explicito };
25
25
  return ATUAL;
26
26
  }
27
27
  function atual() { return ATUAL; }
28
- function limpar() { ATUAL = { modo: null, escopo: '' }; }
28
+ function limpar() { ATUAL = { modo: null, escopo: '', explicito: false }; }
29
+
30
+ /** Sem foto nem vídeo de fora: o "sem" e também ninguém ter dito nada (o padrão, 07/10). */
31
+ function semImagem({ modo } = ATUAL) { return modo !== 'aberto' && modo !== 'fechado'; }
29
32
 
30
33
  /** Os termos do escopo fechado: "site:anthropic.com, claude ai, sonnet" → lista. */
31
34
  function termos(escopo = ATUAL.escopo) {
@@ -41,33 +44,40 @@ function entender(resposta) {
41
44
  return null;
42
45
  }
43
46
 
44
- /* O MODO DEDUZIDO DO PEDIDO. Quem diz no pedido não é perguntado:
45
- "só motion", "sem referência", "sem foto" → sem
46
- "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
47
- "web aberta", "escopo aberto", "pesquisa à vontade" → aberto
48
- O resto volta com `explicito: false` e o terminal pergunta antes de
49
- começar: "eu posso pedir um vídeo mas estar me referindo ao motion"
50
- (Joel, 02/10) — chutar o aberto fazia vídeo com foto e b-roll quando a
51
- pessoa queria só motion. */
47
+ /* O MODO DEDUZIDO DO PEDIDO. O padrão é SEM IMAGEM: "por padrão, NÃO
48
+ usar imagens. Somente quando o usuário pedir explicitamente" (Joel, 07/10).
49
+ O menu 1/2/3 do terminal saiu; quem quer imagem diz no pedido:
50
+ "adicione imagens de apoio", "quero imagens durante o vídeo",
51
+ "use imagens reais", "web aberta" → aberto
52
+ "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
53
+ "só motion", "sem referência", "sem foto" → sem (explícito)
54
+ o resto → sem (o padrão) */
55
+ const PEDE_IMAGEM = /(adicion|coloc|use|usa|usar|quero|bota|pon|insir|inclu|com)\w*\s+(\w+\s+){0,3}(imagens?|fotos?|b-?roll|videos? de (apoio|banco)|cenas reais)\b|(imagens?|fotos?) (de apoio|reais|durante|para ilustrar|pra ilustrar|da (web|internet))|escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)/;
56
+ const NEGA_IMAGEM = /so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima|(nao|nada de) (quero |use |usar |coloc\w* )?(imagens?|fotos?)/;
57
+
52
58
  function inferir(texto) {
53
- const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
54
- if (/so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima/.test(t)) return { modo: 'sem', escopo: '', explicito: true };
59
+ const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
60
+ if (NEGA_IMAGEM.test(t)) return { modo: 'sem', escopo: '', explicito: true };
55
61
  const dominios = String(texto || '').match(/\b[a-z0-9-]+(\.[a-z0-9-]+)+\.?[a-z]{2,}(\/[^\s,]*)?/gi) || [];
56
62
  if (/\b(so|apenas|somente|unicamente)\b[^.]{0,30}\b(site|sites|link|links|fonte|fontes|pagina|de|do|da|em)\b/.test(t) && dominios.length) {
57
63
  return { modo: 'fechado', escopo: dominios.join(', '), explicito: true };
58
64
  }
59
- if (/escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)|com (imagens|fotos) da (web|internet)/.test(t)) {
60
- return { modo: 'aberto', escopo: '', explicito: true };
61
- }
62
- return { modo: 'aberto', escopo: '', explicito: false };
65
+ if (PEDE_IMAGEM.test(t)) return { modo: 'aberto', escopo: '', explicito: true };
66
+ return { modo: 'sem', escopo: '', explicito: false };
63
67
  }
64
68
 
65
69
  /** A instrução para o agente, conforme o modo. */
66
- function instrucao({ modo, escopo } = ATUAL) {
67
- if (modo === 'sem') {
70
+ function instrucao({ modo, escopo, explicito } = ATUAL) {
71
+ // O vídeo sem imagem é motion: o agente escolhe o estilo de motion que serve ao roteiro, não um fixo.
72
+ const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som. Escolha em roteiro.estilo o motion que serve a este '
73
+ + 'roteiro (gerativo, fluido, impacto, vox, marca, lancamento) ou deixe vazio para o rodízio — não repita sempre o mesmo.';
74
+ if (modo === 'sem' && explicito) {
68
75
  return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
69
- + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz e deixe '
70
- + 'o motion gerativo fazer o trabalho (roteiro.estilo "gerativo": a tipografia anima sozinha, sem template).';
76
+ + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz. ' + motion;
77
+ }
78
+ if (modo === 'sem') {
79
+ return '[SEM IMAGEM — o padrão] A pessoa não pediu imagem: não busque foto nem vídeo, não use estudio_midia de banco '
80
+ + 'nem "busca" nas partes. Pesquisar FATOS em texto (pesquisar_referencia) pode, se o assunto pedir. ' + motion;
71
81
  }
72
82
  if (modo === 'fechado') {
73
83
  const t = termos(escopo);
@@ -77,8 +87,9 @@ function instrucao({ modo, escopo } = ATUAL) {
77
87
  + `No roteiro, ponha roteiro.busca = ${JSON.stringify(t)} para o b-roll.`;
78
88
  }
79
89
  if (modo === 'aberto') {
80
- return '[REFERÊNCIAS: ESCOPO ABERTO] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca) '
81
- + 'e dê em cada parte do roteiro um "busca" em inglês com o que mostrar de fundo — gente, lugar, tela, objeto de verdade.';
90
+ return '[REFERÊNCIAS: ESCOPO ABERTO — a pessoa pediu imagens] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca). '
91
+ + 'A imagem é um elemento da narrativa, não o vídeo inteiro: dê "busca" (em inglês: gente, lugar, tela, objeto de verdade) '
92
+ + 'SÓ nas partes em que uma imagem real mostra o que o texto não mostra. Não troque de imagem a cada frase; o resto é motion.';
82
93
  }
83
94
  return '';
84
95
  }
@@ -102,4 +113,4 @@ function filtrar(r, { modo, escopo } = ATUAL) {
102
113
  return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
103
114
  }
104
115
 
105
- module.exports = { definir, atual, limpar, termos, entender, inferir, instrucao, dominios, filtrar };
116
+ module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar };
@@ -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,
@@ -711,6 +719,8 @@ export const Video = () => {
711
719
  return vs[vs.length - 1].v; }
712
720
  : (plano.audio.trilha.volume ?? 0.18)}
713
721
  loop={plano.audio.trilha.loop !== false}
722
+ /* O início da faixa que casa as batidas com os cortes (batida.js). */
723
+ startFrom={Math.round((plano.audio.trilha.inicio || 0) * fps)}
714
724
  />
715
725
  )}
716
726
  {/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
@@ -1005,4 +1015,41 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
1005
1015
  });
1006
1016
  }
1007
1017
 
1008
- module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG };
1018
+ /* ── ABRIR PARA EDITAR ────────────────────────────────────────────────
1019
+ "Quando eu falar pra editar, edita no editor de vídeo open source." O
1020
+ projeto já é Remotion: editar é abrir o Remotion Studio na pasta dele. Ele
1021
+ sobe solto (o PrimoCode não espera) e abre o navegador sozinho. */
1022
+ function comandoStudio(raiz) {
1023
+ return { cmd: 'npx', args: ['remotion', 'studio'], cwd: raiz };
1024
+ }
1025
+
1026
+ function abrirStudio({ raiz, spawnar = spawn } = {}) {
1027
+ if (!raiz || !fs.existsSync(path.join(raiz, 'package.json'))) {
1028
+ return { ok: false, error: 'não achei o projeto Remotion nesta pasta.' };
1029
+ }
1030
+ // O Studio precisa do Remotion instalado. A base compartilhada já vem do primeiro render.
1031
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1032
+ const comp = process.platform === 'win32' ? null : baseCompartilhada(raiz);
1033
+ if (comp && comp.pronta) {
1034
+ try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* segue */ }
1035
+ }
1036
+ if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
1037
+ return { ok: false, error: 'o Remotion ainda não está instalado para este projeto.',
1038
+ comoResolver: 'renderize uma vez com estudio_render (instala o Remotion), depois abra o editor.' };
1039
+ }
1040
+ }
1041
+ const c = comandoStudio(raiz);
1042
+ const win = process.platform === 'win32';
1043
+ try {
1044
+ const p = spawnar(c.cmd, win ? c.args.map(citarWin) : c.args,
1045
+ { cwd: c.cwd, detached: true, stdio: 'ignore', shell: win });
1046
+ if (p && p.on) p.on('error', () => { /* sem npx: o caminho vai na resposta */ });
1047
+ if (p && p.unref) p.unref();
1048
+ } catch (e) {
1049
+ return { ok: false, error: e.message };
1050
+ }
1051
+ return { ok: true, raiz, comando: `cd "${raiz}" && npx remotion studio` };
1052
+ }
1053
+
1054
+ module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG,
1055
+ comandoStudio, abrirStudio };