primocode 11.4.0 → 11.5.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 (85) hide show
  1. package/LICENSES/edit2-terceiros.md +31 -0
  2. package/LICENSES/models-dev-MIT.txt +25 -0
  3. package/README.md +2 -2
  4. package/editor/SKILL.md +24 -0
  5. package/editor/THIRD_PARTY_NOTICES.md +1 -0
  6. package/editor/assets/baixar.json +9 -1
  7. package/editor/helpers/df_limpar.py +29 -0
  8. package/editor/helpers/edit2.py +155 -0
  9. package/editor/helpers/enquadrar_camera.py +77 -0
  10. package/editor/helpers/face_layout.py +20 -0
  11. package/editor/helpers/face_mediapipe.py +147 -0
  12. package/editor/helpers/instalar_df.sh +7 -0
  13. package/editor/helpers/legenda_ass.py +124 -0
  14. package/editor/helpers/look_ffmpeg.py +105 -0
  15. package/editor/helpers/motor_palavras.py +157 -0
  16. package/editor/helpers/primeiro_passe.py +214 -0
  17. package/editor/helpers/som_edit2.py +119 -0
  18. package/editor/helpers/transcribe.py +31 -2
  19. package/editor/pyproject.toml +9 -0
  20. package/editor/references/edit2.md +22 -0
  21. package/editor/uv.lock +3342 -214
  22. package/kits/3d/LICENSES/README.md +11 -0
  23. package/kits/3d/animacoes.js +87 -0
  24. package/kits/3d/base.js +44 -0
  25. package/kits/3d/catalogo.js +38 -0
  26. package/kits/3d/cenario.js +162 -0
  27. package/kits/3d/exemplos/asfalto.mjs +91 -0
  28. package/kits/3d/maquinas.js +97 -0
  29. package/kits/3d/montador.js +142 -0
  30. package/kits/3d/package.json +1 -0
  31. package/kits/3d/personagem.js +86 -0
  32. package/kits/3d/remotion/Maquete.jsx +42 -0
  33. package/kits/3d/remotion/Root.jsx +10 -0
  34. package/kits/3d/render.mjs +73 -0
  35. package/lib/cli/comando-api.js +30 -8
  36. package/lib/cli/modelos-lista.js +58 -0
  37. package/lib/cli/sessao.js +11 -0
  38. package/lib/cli/subcomandos.js +3 -2
  39. package/lib/outra-pasta.js +1 -1
  40. package/lib/permissao-sistema.js +60 -0
  41. package/lib/permission/perigo.js +1 -0
  42. package/lib/permission/permissao.js +1 -0
  43. package/lib/permission/sem-perguntar.js +29 -0
  44. package/lib/plugin/desktop.js +5 -4
  45. package/lib/plugin/estudio/cota.js +4 -2
  46. package/lib/plugin/estudio/dialogo3d.js +71 -0
  47. package/lib/plugin/estudio/effectcraft.js +1 -1
  48. package/lib/plugin/estudio/glifos.js +2 -0
  49. package/lib/plugin/estudio/gsap-local.js +43 -0
  50. package/lib/plugin/estudio/index.js +63 -10
  51. package/lib/plugin/estudio/kit-ae/base.jsx +1 -1
  52. package/lib/plugin/estudio/kit-ae/cenas.jsx +7 -4
  53. package/lib/plugin/estudio/kit-ae/destrava.jsx +211 -0
  54. package/lib/plugin/estudio/maquina-diretor.js +1 -1
  55. package/lib/plugin/estudio/maquina-elementos.js +2 -2
  56. package/lib/plugin/estudio/maquina.js +26 -8
  57. package/lib/plugin/estudio/motion-plano/ajustar.js +139 -0
  58. package/lib/plugin/estudio/motion-plano/critico.js +59 -0
  59. package/lib/plugin/estudio/motion-plano/estrutura.js +111 -0
  60. package/lib/plugin/estudio/motion-plano/fatos.js +137 -0
  61. package/lib/plugin/estudio/motion-plano/index.js +75 -0
  62. package/lib/plugin/estudio/motion-plano/llm.js +59 -0
  63. package/lib/plugin/estudio/motion-plano/motor.js +50 -0
  64. package/lib/plugin/estudio/motion-plano/passos.js +38 -0
  65. package/lib/plugin/estudio/motion-plano/pele.js +36 -0
  66. package/lib/plugin/estudio/motion-plano/plano.js +152 -0
  67. package/lib/plugin/estudio/motion-plano/regua.js +100 -0
  68. package/lib/plugin/estudio/personagem.js +3 -1
  69. package/lib/plugin/estudio/referencias.js +1 -1
  70. package/lib/provider/catalogo.js +161 -0
  71. package/lib/provider/local/catalogo-embutido.json.gz +0 -0
  72. package/lib/provider/local/motor.js +140 -27
  73. package/lib/provider/local/orcamento.js +193 -0
  74. package/lib/provider/local/provedor.js +127 -14
  75. package/lib/provider/local/recorte.js +170 -0
  76. package/lib/provider/local/revezamento.js +121 -0
  77. package/lib/session/runner/act.js +28 -3
  78. package/lib/skill/catalogo.js +5 -1
  79. package/lib/tool/tools.js +5 -1
  80. package/package.json +1 -1
  81. package/vendor/fontes/ttf/ORIGEM.txt +1 -0
  82. package/vendor/fontes/ttf/inter-800.ttf +0 -0
  83. package/vendor/gsap/ORIGEM.txt +6 -0
  84. package/vendor/gsap/gsap.min.js +11 -0
  85. package/voz/primo_voz/chat.html +167 -5
@@ -0,0 +1,152 @@
1
+ /**
2
+ * plano.js — etapa 3 do motion: o PLANO cena por cena.
3
+ *
4
+ * { texto, palavra_chave, heroi, duracao_s, entrada, foto?, tamanho_px, papel }
5
+ *
6
+ * O modelo escolhe herói, duração e entrada seguindo a régua do Destrava (regua.js: números, não adjetivos).
7
+ * O código confere o que dá pra conferir e CONSERTA o resto — foto sem foto real, marca fora do fim, duas cores cheias,
8
+ * duração fora de 1,2–5,5 s — porque regra no prompt o modelo esquece. O plano que sai daqui é o que o motor recebe.
9
+ */
10
+ 'use strict';
11
+
12
+ const { pedirJson } = require('./llm.js');
13
+ const { REGUA, tamanho, encaixar, textoDoPrompt } = require('./regua.js');
14
+ const { marcar, contar, MAX_PALAVRAS } = require('./estrutura.js');
15
+ const Fatos = require('./fatos.js');
16
+
17
+ const HEROIS = ['tipografia', 'foto', 'numero', 'cor_cheia', 'marca'];
18
+ const ENTRADAS = ['mascara-sobe', 'escala-assenta', 'palavra-a-palavra', 'zoom-atravessa', 'cartao-voa', 'cor-expande'];
19
+ const ENTRADA_DO_HEROI = { foto: 'cartao-voa', cor_cheia: 'cor-expande', marca: 'mascara-sobe', numero: 'escala-assenta', tipografia: 'palavra-a-palavra' };
20
+
21
+ const sistema = () => `Você é o diretor de motion de um vídeo vertical em PT-BR. Recebe as batidas do roteiro e devolve o PLANO cena por cena, uma cena por batida, na mesma ordem.
22
+ Responda SÓ JSON: {"cenas":[{"texto":"a frase, com a palavra-chave entre *asteriscos*","palavra_chave":"...","heroi":"${HEROIS.join('|')}","duracao_s":3,"entrada":"${ENTRADAS.join('|')}","foto":"caminho de uma foto da lista, só se heroi=foto"}]}
23
+ Significado: heroi = o que domina o quadro. tipografia = a palavra gigante. foto = cartão de foto real. numero = um número real dos fatos em tamanho enorme. cor_cheia = o fundo vira cor cheia (uma única vez no vídeo). marca = a marca real (só na última).
24
+ entrada: mascara-sobe = a linha varre para cima; escala-assenta = nasce grande e assenta; palavra-a-palavra = a frase se monta em pedaços; zoom-atravessa = a câmera atravessa a palavra (use no máximo uma vez); cartao-voa = o cartão de foto voa em 3D; cor-expande = o círculo de cor cresce.
25
+ Não troque o sentido nem o assunto das batidas; pode apertar o texto. NÃO invente fato: datas, nomes, números, lugares e marca só dos fatos.
26
+
27
+ ${textoDoPrompt()}`;
28
+
29
+ const txt = (v, n) => String(v || '').replace(/\s+/g, ' ').trim().slice(0, n);
30
+
31
+ /** Conserta o plano do modelo contra as regras. Puro. Devolve { cenas, ajustes[] }. */
32
+ function consertar(bruto, batidas, { fotos = [], marca = '', alvoS = [15, 30], fonte = null } = {}) {
33
+ const ajustes = [];
34
+ const brutas = Array.isArray(bruto && bruto.cenas) ? bruto.cenas : [];
35
+ const n = batidas.length;
36
+ let cenas = batidas.map((b, i) => {
37
+ const c = brutas[i] || {};
38
+ let texto = txt(c.texto, 120) || b.frase;
39
+ if (contar(texto) > MAX_PALAVRAS) { ajustes.push(`cena ${i + 1}: texto longo, voltou pra frase da batida`); texto = b.frase; }
40
+ if (fonte) {
41
+ const l = Fatos.limpar(texto, fonte);
42
+ if (!l.removidos.length && l.frase && Fatos.norm(l.frase) !== Fatos.norm(texto)) { ajustes.push(`cena ${i + 1}: o fato do usuário voltou inteiro (${l.frase})`); texto = l.frase; }
43
+ if (l.removidos.length) { ajustes.push(`cena ${i + 1}: fato que o usuário não disse (${l.removidos.join(', ')}), ${l.frase ? 'tirado' : 'voltou pra frase da batida'}`); texto = l.frase || b.frase; }
44
+ }
45
+ const m = marcar(texto, c.palavra_chave || b.palavra_chave);
46
+ return {
47
+ papel: b.papel, texto: m.frase, palavra_chave: m.chave,
48
+ heroi: HEROIS.includes(c.heroi) ? c.heroi : 'tipografia',
49
+ duracao_s: Number(c.duracao_s) || REGUA.cena.media_s,
50
+ entrada: ENTRADAS.includes(c.entrada) ? c.entrada : null,
51
+ foto: c.foto ? String(c.foto) : undefined,
52
+ };
53
+ });
54
+ // regra 4: foto só com foto real; no máximo 3 de 9 (≈ 1/3) das cenas
55
+ const maxFoto = fotos.length ? Math.max(1, Math.round(n * REGUA.foto.cenas_com_foto / REGUA.foto.de)) : 0;
56
+ let usadas = 0;
57
+ cenas.forEach((c, i) => {
58
+ if (c.heroi === 'foto') {
59
+ if (!fotos.length || usadas >= maxFoto) { ajustes.push(`cena ${i + 1}: ${fotos.length ? 'foto demais' : 'sem foto real'}, virou tipografia`); c.heroi = 'tipografia'; c.foto = undefined; }
60
+ else { c.foto = fotos.includes(c.foto) ? c.foto : fotos[usadas % fotos.length]; usadas++; }
61
+ } else c.foto = undefined;
62
+ });
63
+ // a pessoa mandou foto real e o modelo não usou nenhuma: a melhor cena de prova (nem a primeira, nem a última) vira o cartão de foto
64
+ if (fotos.length && n >= 4 && !cenas.some((c) => c.heroi === 'foto')) {
65
+ const k = [cenas.findIndex((c, i) => i > 0 && i < n - 1 && c.papel === 'prova' && c.heroi === 'tipografia'), cenas.findIndex((c, i) => i > 0 && i < n - 1 && c.heroi === 'tipografia')].find((i) => i >= 0);
66
+ if (k != null) { cenas[k].heroi = 'foto'; cenas[k].foto = fotos[0]; ajustes.push(`cena ${k + 1}: a foto real não foi usada, entrou aqui`); }
67
+ }
68
+ // regra 6: marca real só na última (e só se existe)
69
+ cenas.forEach((c, i) => {
70
+ const ultima = i === n - 1;
71
+ if (c.heroi === 'marca' && (!ultima || !marca)) { ajustes.push(`cena ${i + 1}: marca fora do fim, virou tipografia`); c.heroi = 'tipografia'; }
72
+ });
73
+ if (marca && cenas[n - 1].heroi !== 'marca') { cenas[n - 1].heroi = 'marca'; ajustes.push('última cena: marca real entrou no fim'); }
74
+ // regra 5: exatamente 1 quadro de cor cheia (quando há ≥ 5 cenas); nunca na primeira nem na marca
75
+ const cheias = cenas.map((c, i) => (c.heroi === 'cor_cheia' ? i : -1)).filter((i) => i >= 0);
76
+ if (n >= 5) {
77
+ let manter = cheias.find((i) => i > 0 && i < n - 1);
78
+ if (manter == null) {
79
+ manter = cenas.findIndex((c, i) => c.papel === 'virada' && i > 0 && c.heroi === 'tipografia');
80
+ if (manter < 0) manter = Math.floor(n * 0.6);
81
+ cenas[manter].heroi = 'cor_cheia';
82
+ ajustes.push(`cena ${manter + 1}: virou o quadro de cor cheia`);
83
+ }
84
+ cenas.forEach((c, i) => { if (c.heroi === 'cor_cheia' && i !== manter) { c.heroi = 'tipografia'; ajustes.push(`cena ${i + 1}: só uma cor cheia, virou tipografia`); } });
85
+ } else cenas.forEach((c) => { if (c.heroi === 'cor_cheia') c.heroi = 'tipografia'; });
86
+ // tempo: 1,2–5,5 s por cena; o total entre 15 e 30 s (escala proporcional, depois reclampa)
87
+ cenas.forEach((c) => { c.duracao_s = Math.min(REGUA.cena.max_s, Math.max(REGUA.cena.min_s, c.duracao_s)); });
88
+ let total = cenas.reduce((a, c) => a + c.duracao_s, 0);
89
+ if (total < alvoS[0] || total > alvoS[1]) {
90
+ const k = (total < alvoS[0] ? alvoS[0] : alvoS[1]) / total;
91
+ cenas.forEach((c) => { c.duracao_s = Math.min(REGUA.cena.max_s, Math.max(REGUA.cena.min_s, c.duracao_s * k)); });
92
+ ajustes.push(`duração total ${total.toFixed(1)} s reescalada para o intervalo de ${alvoS[0]} a ${alvoS[1]} s`);
93
+ }
94
+ cenas.forEach((c, i) => {
95
+ c.duracao_s = Math.round(c.duracao_s * 10) / 10;
96
+ if (!c.entrada) c.entrada = ENTRADA_DO_HEROI[c.heroi];
97
+ if (c.heroi === 'foto') c.entrada = 'cartao-voa';
98
+ if (c.heroi === 'cor_cheia') c.entrada = 'cor-expande';
99
+ if (c.heroi === 'foto' || c.heroi === 'marca') c.tamanho_px = REGUA.texto.apoio_px[1]; // apoio: nunca abaixo de 56 px
100
+ else {
101
+ // o herói nunca fica abaixo do mínimo da régua (150 px) e cabe em até 3 linhas: sobrou palavra, ela sai (a palavra-chave fica)
102
+ const e = encaixar(c.texto);
103
+ if (e.cortou) { ajustes.push(`cena ${i + 1}: texto longo pro herói (150 px, 3 linhas), ficou "${e.texto.replace(/\*/g, '')}"`); c.texto = e.texto; }
104
+ c.tamanho_px = e.tam;
105
+ }
106
+ });
107
+ // variedade: se quase tudo entra do mesmo jeito, cada papel ganha o seu (a régua tem um movimento por cena)
108
+ const POR_PAPEL = { gancho: 'mascara-sobe', virada: 'escala-assenta', prova: 'palavra-a-palavra', fecho: 'mascara-sobe' };
109
+ const freq = {};
110
+ cenas.forEach((c) => { freq[c.entrada] = (freq[c.entrada] || 0) + 1; });
111
+ if (Math.max(...Object.values(freq)) > cenas.length * 0.6) {
112
+ cenas.forEach((c) => { if (!['foto', 'cor_cheia'].includes(c.heroi)) c.entrada = POR_PAPEL[c.papel] || c.entrada; });
113
+ ajustes.push('entradas iguais demais: cada papel ganhou a sua');
114
+ }
115
+ // zoom-atravessa no máximo uma vez
116
+ let z = 0;
117
+ cenas.forEach((c, i) => { if (c.entrada === 'zoom-atravessa' && ++z > 1) { c.entrada = 'escala-assenta'; ajustes.push(`cena ${i + 1}: só um zoom-atravessa`); } });
118
+ total = Math.round(cenas.reduce((a, c) => a + c.duracao_s, 0) * 10) / 10;
119
+ return { cenas, total_s: total, ajustes };
120
+ }
121
+
122
+ /** O que a régua ainda reprova depois do conserto (deve ser vazio). Puro. */
123
+ function conferir(plano, { fotos = [], marca = '' } = {}) {
124
+ const erros = [];
125
+ const cs = plano.cenas;
126
+ if (cs.filter((c) => c.heroi === 'cor_cheia').length > 1) erros.push('mais de uma cor cheia');
127
+ if (cs.length >= 5 && cs.filter((c) => c.heroi === 'cor_cheia').length !== 1) erros.push('falta o quadro de cor cheia');
128
+ if (cs.some((c, i) => c.heroi === 'marca' && i !== cs.length - 1)) erros.push('marca fora do fim');
129
+ if (cs.some((c) => c.heroi === 'foto' && !fotos.length)) erros.push('foto sem foto real');
130
+ if (marca && cs[cs.length - 1].heroi !== 'marca') erros.push('sem marca no fim');
131
+ if (cs.some((c) => c.duracao_s < REGUA.cena.min_s || c.duracao_s > REGUA.cena.max_s)) erros.push('cena fora de 1,2 a 5,5 s');
132
+ if (cs.some((c) => !/\*[^*]+\*/.test(c.texto))) erros.push('frase sem palavra-chave');
133
+ return erros;
134
+ }
135
+
136
+ /** @param {object} briefing @param {Array} batidas @param {object} deps { llm } */
137
+ async function montarPlano(briefing, batidas, deps = {}) {
138
+ const llm = deps.llm;
139
+ if (!llm) return { ok: false, error: 'sem modelo para montar o plano.' };
140
+ const fotos = (briefing.fotos || []).map(String);
141
+ const marca = (briefing.fatos && briefing.fatos.marca) || '';
142
+ const corpo = JSON.stringify({ batidas: batidas.map((b) => ({ papel: b.papel, frase: b.frase, palavra_chave: b.palavra_chave })), fatos: briefing.fatos, tom: briefing.tom, fotos_reais: fotos, marca_real: marca || null });
143
+ const bruto = await pedirJson(llm, [{ role: 'system', content: sistema() }, { role: 'user', content: corpo }], { temperatura: 0.6, maxTokens: 4000 });
144
+ const alvoS = deps.alvoS || [15, 30];
145
+ const r = consertar(bruto, batidas, { fotos, marca, alvoS, fonte: deps.fonte });
146
+ const plano = { assunto: briefing.pedido, objetivo: briefing.objetivo, tom: briefing.tom, virada: briefing.virada, marca: marca || null, cenas: r.cenas, total_s: r.total_s, ajustes: r.ajustes };
147
+ const erros = conferir(plano, { fotos, marca });
148
+ if (erros.length) return { ok: false, error: `plano reprovado pela régua: ${erros.join('; ')}`, plano };
149
+ return { ok: true, plano };
150
+ }
151
+
152
+ module.exports = { montarPlano, consertar, conferir, HEROIS, ENTRADAS, sistema };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * regua.js — a gramática do vídeo "Destrava Essencial" (aprovada pelo Joel), em NÚMEROS.
3
+ *
4
+ * Fonte: ~/www/videos/destrava-essencial-pc/video (src/style.json, src/lib/anim.ts, src/scenes/Kinetic.tsx,
5
+ * src/scene-times.json). Canvas 1080×1920, 30 fps, 9 cenas, 27 s. Cada número abaixo aponta de onde veio.
6
+ * Os frames viram segundos aqui (÷ 30) porque o plano fala em segundos.
7
+ *
8
+ * As 6 regras:
9
+ * 1 tipografia enorme é o herói; só a palavra-chave ganha degradê/cor de destaque
10
+ * 2 uma ideia por quadro; a frase se monta em pedaços na hora da fala
11
+ * 3 copy com virada
12
+ * 4 fotos reais do assunto em cartões arredondados que se mexem; nunca UI/ícone inventado
13
+ * 5 um único quadro de cor cheia quebra o fundo
14
+ * 6 marca real só no fim
15
+ */
16
+ 'use strict';
17
+
18
+ const FPS = 30;
19
+ const s = (frames) => Math.round((frames / FPS) * 100) / 100;
20
+
21
+ // scene-times.json: [0,101,265,359,395,463,518,605,691] + fim 810 → duração de cada cena
22
+ const CENAS_S = [101, 164, 94, 36, 68, 55, 87, 86, 119].map(s);
23
+
24
+ const REGUA = Object.freeze({
25
+ fps: FPS,
26
+ // — tempo —
27
+ cena: { min_s: Math.min(...CENAS_S), max_s: Math.round(Math.max(...CENAS_S) * 10) / 10, media_s: s(810 / 9), exemplos_s: CENAS_S }, // 1,2 a 5,5 s; média 3 s
28
+ video: { cenas: 9, duracao_s: 27 },
29
+ palavra: { entrada_s: s(12), passo_entre_palavras_s: [s(4), s(5)], blur_px: 10, sobe: 0.28, mola: { damping: 11, stiffness: 140, mass: 0.7 } }, // style.json motion.word
30
+ linha: { varre_s: s(18), assenta_s: s(22), escala_de: [1.3, 1.7], tracking_em: [0.05, -0.045], blur_px: 9 }, // Kinetic.tsx Line
31
+ cena_entra_s: s(12), cena_sai_s: s(10), cena_sobreposicao_s: s(17), cena_desloca_px: 260, cena_blur_px: 16, // Kinetic.tsx Scn / style.json scene
32
+ easing: { saida: [0.22, 1, 0.36, 1], entra_e_sai: [0.65, 0, 0.35, 1], e5: '1-(1-t)^5' }, // style.json ease; Kinetic e5
33
+ batida_s: s(14.4), // 14,4 f ≈ 0,48 s
34
+ camera: { deriva_zoom: [1.018, 0.012], soco_zoom: 0.03, soco_decai_s: s(7), soco_na_batida: true }, // camAt()
35
+ atravessa: { zoom_final: 12, s: s(17) }, // S7 zoom-through
36
+ // — tipografia (px em 1080 de largura) —
37
+ texto: { peso: [800, 900], linhas_max: 2, palavras_por_linha_max: 7, tracking_em: -0.02, entreLinhas: 1.08, destaque_por_frase: 1,
38
+ apoio_px: [64, 118], heroi_px: [150, 410], heroi_linhas_max: 3, apoio_min_px: 56 }, // style.json font; Kinetic sizes
39
+ // — cor cheia, foto, marca —
40
+ cor_cheia: { quadros: 1, de: 9, expande_s: s(16) }, // S8: círculo radial, 1 cena
41
+ foto: { cartao_raio_px: 36, sombra: '0 40px 90px rgba(29,29,31,.28)', cartoes: [[400, 530], [980, 1060], [330, 440]], cenas_com_foto: 3, de: 9, voa_s: s(14) },
42
+ marca: { cenas: 1, de: 9, posicao: 'ultima', revela_s: s(26) }, // S9: logo só no fim
43
+ });
44
+
45
+ /** Tamanho do herói em px pela contagem de palavras (Destrava: "Não." 410 · "IA." 282 · "Usar IA" 248 · "Mais uma palavra" 180–210 · "Sábado," 150). Nunca abaixo do mínimo da régua (150). */
46
+ function tamanho(nPalavras) {
47
+ const min = REGUA.texto.heroi_px[0];
48
+ if (nPalavras <= 1) return 360;
49
+ if (nPalavras === 2) return 250;
50
+ if (nPalavras === 3) return 200;
51
+ return min;
52
+ }
53
+
54
+ /** Quantas linhas o texto ocupa em `tam` px (Inter 800, tracking -0.02 em ≈ 0,6 em por letra; a palavra-chave, 18% maior). Mesma conta do kit. */
55
+ function linhasDe(texto, tam, { largura = 1080 * (1 - 2 * 0.08) * 0.98 } = {}) {
56
+ const ps = String(texto || '').split(/\s+/).filter(Boolean);
57
+ const comKw = ps.length > 1;
58
+ let linhas = 1, uso = 0;
59
+ for (const p of ps) {
60
+ const n = p.replace(/\*/g, '').length, w0 = n * tam * 0.58;
61
+ const t = Math.min(p.includes('*') && comKw ? tam * Math.min(1.18, Math.max(1, 0.98 * largura / w0)) : tam, REGUA.texto.heroi_px[1]); // a palavra-chave cresce até 18%, só enquanto cabe
62
+ const w = n * t * 0.58, esp = tam * 0.27;
63
+ if (w > largura) return 99; // palavra que não cabe sozinha na linha
64
+ if (uso && uso + esp + w > largura) { linhas++; uso = w; } else uso = uso ? uso + esp + w : w;
65
+ }
66
+ return linhas;
67
+ }
68
+
69
+ /** O herói que cabe: no mínimo da régua (150 px) ele tem que caber em até 3 linhas; sobrando, tira palavras longe da palavra-chave (começo antes do fim).
70
+ * O tamanho é o da contagem de palavras, reduzido em passos de 8% só até caber, e nunca abaixo de 150. Devolve { texto, tam, cortou }. */
71
+ function encaixar(texto, { max = REGUA.texto.heroi_linhas_max } = {}) {
72
+ const min = REGUA.texto.heroi_px[0];
73
+ let ps = String(texto || '').split(/\s+/).filter(Boolean), cortou = false;
74
+ const kw = () => Math.max(0, ps.findIndex((p) => p.includes('*')));
75
+ while (ps.length > 1 && linhasDe(ps.join(' '), min) > max) {
76
+ const k = kw();
77
+ if (k > 0 && k >= ps.length - 1 - k) ps = ps.slice(1); else if (k < ps.length - 1) ps = ps.slice(0, -1); else ps = ps.slice(1);
78
+ cortou = true;
79
+ }
80
+ const t = ps.join(' ');
81
+ let tam = tamanho(ps.length);
82
+ while (tam > min && linhasDe(t, tam) > max) tam = Math.max(min, Math.round(tam * 0.92));
83
+ return { texto: t, tam, cortou };
84
+ }
85
+
86
+ /** O bloco de regras que vai no prompt do plano. Números, não adjetivos. */
87
+ function textoDoPrompt() {
88
+ const R = REGUA;
89
+ return `RÉGUA (medida num vídeo aprovado de 27 s, 9 cenas, 1080×1920, 30 fps):
90
+ 1. TIPOGRAFIA É O HERÓI. Texto de herói: ${R.texto.heroi_px[0]} a ${R.texto.heroi_px[1]} px, peso 800 a 900, no máximo ${R.texto.heroi_linhas_max} linhas e ${R.texto.palavras_por_linha_max} palavras por linha; texto de apoio ${R.texto.apoio_px[0]} a ${R.texto.apoio_px[1]} px. Tamanho por palavras: 1 palavra ≈ 360 px, 2 ≈ 250, 3 ≈ 200, 4 ou mais = 150 (nunca abaixo disso; o texto herói é curto, até 3 linhas, com a palavra-chave maior). SÓ a palavra-chave leva a cor de destaque (${R.texto.destaque_por_frase} por frase).
91
+ 2. UMA IDEIA POR QUADRO. A frase se monta em pedaços: cada palavra entra em ${R.palavra.entrada_s} s, com ${R.palavra.passo_entre_palavras_s[0]} a ${R.palavra.passo_entre_palavras_s[1]} s entre palavras (blur ${R.palavra.blur_px} px sumindo, sobe ${R.palavra.sobe * 100}% do tamanho). A linha de herói varre para cima em ${R.linha.varre_s} s e assenta em ${R.linha.assenta_s} s, vinda de escala ${R.linha.escala_de[0]} a ${R.linha.escala_de[1]}.
92
+ 3. COPY COM VIRADA. A pergunta que o público faria (ou o clichê do assunto) no gancho, depois a resposta curta que quebra a expectativa, específica do assunto. Nada de frase de catálogo.
93
+ 4. FOTO REAL EM CARTÃO ARREDONDADO (raio ${R.foto.cartao_raio_px} px, sombra suave) que se mexe: voa em ${R.foto.voa_s} s, carrossel 3D ou grade inclinada. ${R.foto.cenas_com_foto} de ${R.foto.de} cenas levam foto, e SÓ se existe foto real do assunto. Sem foto, a cena é só tipografia. Nunca UI, ícone ou ilustração inventada.
94
+ 5. UM ÚNICO QUADRO DE COR CHEIA quebra o fundo (${R.cor_cheia.quadros} de ${R.cor_cheia.de} cenas; o círculo de cor expande em ${R.cor_cheia.expande_s} s). Só um.
95
+ 6. MARCA REAL SÓ NO FIM: a última cena (e só ela) mostra a marca, se a marca está nos fatos.
96
+ TEMPO: cena de ${R.cena.min_s} a ${R.cena.max_s} s, média ${R.cena.media_s} s (cena de uma palavra de virada é a mais curta, 1,2 a 1,8 s; cena que explica pode ir a 4 a 5,5 s). Vídeo de 15 a 30 s. Troca de cena: entra em ${R.cena_entra_s} s com deslocamento de ${R.cena_desloca_px} px e blur ${R.cena_blur_px} px, sai em ${R.cena_sai_s} s, sobrepõe ${R.cena_sobreposicao_s} s. Câmera: deriva contínua de ±${R.camera.deriva_zoom[1] * 100}% de zoom e um soco de ${R.camera.soco_zoom * 100}% que decai em ${R.camera.soco_decai_s} s, na batida (a cada ${R.batida_s} s).
97
+ EASING: saída cubic-bezier(${R.easing.saida.join(', ')}); entra-e-sai cubic-bezier(${R.easing.entra_e_sai.join(', ')}); assentar = ${R.easing.e5}. Nunca linear.`;
98
+ }
99
+
100
+ module.exports = { REGUA, tamanho, linhasDe, encaixar, textoDoPrompt, CENAS_S };
@@ -485,6 +485,8 @@ function montarPersonagem({ roteiro, largura = 1920, altura = 1080, tempos = [],
485
485
  const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos });
486
486
  if (!partitura.falas.length) return { ok: false, error: 'o roteiro veio sem fala: o personagem precisa de texto em cada parte.' };
487
487
  const DUR = partitura.duracao;
488
+ // motor novo (kits/3d): a mesma partitura, traduzida para o montador de maquete. O plano antigo segue junto.
489
+ const roteiro3d = roteiro.motor === 'montador' ? require('./dialogo3d.js').roteiroDeDialogo(partitura) : undefined;
488
490
  const efeitos = [];
489
491
  const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
490
492
  for (const f of partitura.falas) if (f.destaque) som(f.de + 0.3, 'pop', 0.16);
@@ -501,7 +503,7 @@ function montarPersonagem({ roteiro, largura = 1920, altura = 1080, tempos = [],
501
503
  },
502
504
  };
503
505
  return {
504
- ok: true, plano, partitura,
506
+ ok: true, plano, partitura, ...(roteiro3d ? { roteiro3d } : {}),
505
507
  resumo: { visual: partitura.visual, cenas: 1, segundos: DUR, fps: FPS, cenario: partitura.cenario.nome, elenco: partitura.elenco.map((e) => `${e.rotulo} (${e.tipo === 'animal' ? e.especie : e.tipo})`),
506
508
  figurantes: partitura.figurantes.map((e) => (e.tipo === 'animal' ? e.especie : e.tipo)), lugares: partitura.mundos.map((m) => m.nome), planos: partitura.planos.length, falas: partitura.falas.length },
507
509
  };
@@ -87,7 +87,7 @@ function instrucaoDoModo({ modo, escopo, explicito } = ATUAL) {
87
87
  + 'CRIE a cena: escreva um React (write_file, ex.: componentes/cena-x.jsx, export default ({el, t}) => …, t em segundos desde o '
88
88
  + 'começo da parte; el.dados traz palavras [{texto, t}], cores {fundo, texto, acento}, fontes, largura, altura) e ponha '
89
89
  + 'componente:"componentes/cena-x.jsx" naquela parte. A voz, a trilha, os sons e o fundo continuam do Estúdio. '
90
- + 'Vídeo inteiro desenhado por você: escreva uma composição Hyperframes (pasta com index.html, data-start/data-duration, GSAP), '
90
+ + 'Vídeo inteiro desenhado por você: escreva uma composição Hyperframes (pasta com index.html, data-start/data-duration, GSAP: use `gsap.` direto, sem <script src> nem CDN, o motor embute o GSAP local), '
91
91
  + 'use a voz do estudio_narrar e os sons de midia/, e estudio_render motor:"hyperframes" html:"<pasta>". '
92
92
  /* O ROTEIRO (13/10: "o roteiro que monta é horrível" — eram os títulos da landing em fila). */
93
93
  + 'O ROTEIRO é uma história de 15 a 25 s: gancho (a situação de quem assiste) → o que o produto FAZ → UMA prova concreta '
@@ -0,0 +1,161 @@
1
+ /**
2
+ * catalogo.js — o catálogo de modelos, lido do models.dev (licença MIT).
3
+ *
4
+ * Mesma ideia do OpenCode (packages/core/src/models-dev.ts): uma lista pública
5
+ * de provedores e modelos, com env da chave, URL e capacidades de cada um.
6
+ * Três camadas, da mais nova para a mais velha:
7
+ *
8
+ * 1. rede — GET https://models.dev/api.json, com ETag (304 não rebaixa 6 MB)
9
+ * 2. cache — ~/.primocode/cache/models-dev.json, vale 12 h (stale ainda serve)
10
+ * 3. embutido — lib/provider/local/catalogo-embutido.json.gz, para offline na 1ª vez
11
+ *
12
+ * O formato guardado é ENXUTO (só o que o PrimoCode usa), não o JSON cru:
13
+ * { provedores: { <id>: { nome, env:[], api, modelos: { <id>: {
14
+ * nome, ferr, visao, raciocinio, ctx, saida, custoIn, custoOut } } } } }
15
+ * Só entram provedores que falam o formato OpenAI (/chat/completions): os que
16
+ * têm `api` no catálogo (npm openai-compatible) e os que o catálogo deixa sem
17
+ * `api` mas cuja URL é conhecida (BASES_CONHECIDAS, abaixo).
18
+ *
19
+ * Nenhuma chave e nenhuma URL privada moram aqui. Gateway da empresa entra
20
+ * pelo `~/.primocode/provedores.json` da pessoa (provedor.js), não por este arquivo.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const zlib = require('zlib');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ const URL_CATALOGO = 'https://models.dev/api.json';
31
+ const VALIDADE_MS = 12 * 60 * 60 * 1000;
32
+ const EMBUTIDO = path.join(__dirname, 'local', 'catalogo-embutido.json.gz'); // gzip: 1 MB -> ~150 KB no pacote
33
+ const CACHE = () => path.join(os.homedir(), '.primocode', 'cache', 'models-dev.json');
34
+
35
+ /* Provedores que o models.dev lista sem `api` (usam um SDK próprio), mas que
36
+ expõem /chat/completions no formato OpenAI nesta URL pública. */
37
+ const BASES_CONHECIDAS = {
38
+ groq: 'https://api.groq.com/openai/v1',
39
+ openai: 'https://api.openai.com/v1',
40
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai',
41
+ xai: 'https://api.x.ai/v1',
42
+ mistral: 'https://api.mistral.ai/v1',
43
+ cerebras: 'https://api.cerebras.ai/v1',
44
+ deepseek: 'https://api.deepseek.com/v1',
45
+ togetherai: 'https://api.together.xyz/v1',
46
+ perplexity: 'https://api.perplexity.ai',
47
+ deepinfra: 'https://api.deepinfra.com/v1/openai',
48
+ anthropic: 'https://api.anthropic.com/v1',
49
+ };
50
+
51
+ /** Do JSON cru do models.dev para o formato enxuto. */
52
+ function normalizar(cru) {
53
+ const provedores = {};
54
+ for (const [id, p] of Object.entries(cru || {})) {
55
+ if (!p || typeof p !== 'object' || !p.models) continue;
56
+ const env = Array.isArray(p.env) ? p.env.filter(Boolean) : [];
57
+ const api = p.api || BASES_CONHECIDAS[id];
58
+ if (!api || !env.length) continue;
59
+ const modelos = {};
60
+ for (const [mid, m] of Object.entries(p.models)) {
61
+ if (!m) continue;
62
+ const saidas = (m.modalities && m.modalities.output) || ['text'];
63
+ if (!saidas.includes('text') || m.type) continue; // sem áudio/imagem/embedding
64
+ if (m.status === 'deprecated') continue;
65
+ const ent = (m.modalities && m.modalities.input) || ['text'];
66
+ modelos[mid] = {
67
+ nome: m.name || mid,
68
+ ferr: !!m.tool_call,
69
+ visao: ent.includes('image'),
70
+ raciocinio: !!m.reasoning,
71
+ ctx: (m.limit && m.limit.context) || 0,
72
+ saida: (m.limit && m.limit.output) || 0,
73
+ custoIn: m.cost && typeof m.cost.input === 'number' ? m.cost.input : null,
74
+ custoOut: m.cost && typeof m.cost.output === 'number' ? m.cost.output : null,
75
+ };
76
+ }
77
+ if (Object.keys(modelos).length) provedores[id] = { nome: p.name || id, env, api, modelos };
78
+ }
79
+ return { provedores };
80
+ }
81
+
82
+ function lerJson(arq) {
83
+ try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return null; }
84
+ }
85
+
86
+ function valido(c) {
87
+ return !!(c && c.provedores && typeof c.provedores === 'object' && Object.keys(c.provedores).length);
88
+ }
89
+
90
+ /** Cache + embutido, sem rede (a CLI precisa listar na hora). */
91
+ function carregarSync() {
92
+ const cache = lerJson(CACHE());
93
+ if (cache && valido(cache.dados)) return { ...cache.dados, origem: 'cache', baixadoEm: cache.baixadoEm };
94
+ let emb = null;
95
+ try { emb = JSON.parse(zlib.gunzipSync(fs.readFileSync(EMBUTIDO)).toString('utf8')); } catch { /* sem embutido */ }
96
+ return valido(emb) ? { ...emb, origem: 'embutido' } : { provedores: {}, origem: 'vazio' };
97
+ }
98
+
99
+ function cacheFresco() {
100
+ const cache = lerJson(CACHE());
101
+ return !!(cache && valido(cache.dados) && Date.now() - cache.baixadoEm < VALIDADE_MS);
102
+ }
103
+
104
+ /**
105
+ * Baixa do models.dev se o cache venceu (ou `forcar`). Nunca lança: offline,
106
+ * fica o que já havia. Devolve { ok, origem, erro? }.
107
+ */
108
+ async function atualizar({ forcar = false, buscar = fetch, agora = Date.now } = {}) {
109
+ if (process.env.PRIMOCODE_CATALOGO_OFFLINE === '1') return { ok: false, origem: carregarSync().origem, erro: 'offline' };
110
+ if (!forcar && cacheFresco()) return { ok: true, origem: 'cache' };
111
+ const antigo = lerJson(CACHE());
112
+ try {
113
+ const headers = antigo && antigo.etag ? { 'If-None-Match': antigo.etag } : {};
114
+ const r = await buscar(process.env.PRIMOCODE_CATALOGO_URL || URL_CATALOGO, { headers, signal: AbortSignal.timeout(20000) });
115
+ if (r.status === 304 && antigo) {
116
+ gravar({ ...antigo, baixadoEm: agora() });
117
+ return { ok: true, origem: 'cache' };
118
+ }
119
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
120
+ const dados = normalizar(await r.json());
121
+ if (!valido(dados)) throw new Error('catálogo vazio');
122
+ gravar({ baixadoEm: agora(), etag: (r.headers && r.headers.get && r.headers.get('etag')) || '', dados });
123
+ return { ok: true, origem: 'rede' };
124
+ } catch (e) {
125
+ return { ok: false, origem: carregarSync().origem, erro: e.message };
126
+ }
127
+ }
128
+
129
+ function gravar(obj) {
130
+ const arq = CACHE();
131
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
132
+ const tmp = `${arq}.${process.pid}.tmp`;
133
+ fs.writeFileSync(tmp, JSON.stringify(obj));
134
+ fs.renameSync(tmp, arq);
135
+ }
136
+
137
+ /** Todos os modelos como "provedor/modelo", com as capacidades. */
138
+ function listar({ catalogo = carregarSync(), provedor = '', filtro = '', soFerramentas = false, soGratis = false } = {}) {
139
+ const f = String(filtro || '').toLowerCase();
140
+ const saida = [];
141
+ for (const [pid, p] of Object.entries(catalogo.provedores)) {
142
+ if (provedor && pid !== provedor) continue;
143
+ for (const [mid, m] of Object.entries(p.modelos)) {
144
+ if (soFerramentas && !m.ferr) continue;
145
+ if (soGratis && !(m.custoIn === 0 && m.custoOut === 0)) continue;
146
+ const ref = `${pid}/${mid}`;
147
+ if (f && !`${ref} ${m.nome}`.toLowerCase().includes(f)) continue;
148
+ saida.push({ ref, provedor: pid, id: mid, ...m });
149
+ }
150
+ }
151
+ return saida.sort((a, b) => a.ref.localeCompare(b.ref));
152
+ }
153
+
154
+ /** Capacidades de um modelo, ou null se o catálogo não o conhece (aí vale o benefício da dúvida). */
155
+ function capacidades(provedor, modelo, catalogo = carregarSync()) {
156
+ const p = catalogo.provedores[provedor];
157
+ const m = p && p.modelos[modelo];
158
+ return m ? { ferramentas: m.ferr, visao: m.visao, raciocinio: m.raciocinio, contexto: m.ctx, saida: m.saida, custoIn: m.custoIn, custoOut: m.custoOut } : null;
159
+ }
160
+
161
+ module.exports = { URL_CATALOGO, VALIDADE_MS, BASES_CONHECIDAS, normalizar, carregarSync, atualizar, listar, capacidades, CACHE };