primocode 10.5.0 → 10.7.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 (44) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +117 -24
  3. package/lib/act.js +141 -9
  4. package/lib/agents-md.js +140 -0
  5. package/lib/auditoria.js +122 -0
  6. package/lib/browser.js +48 -1
  7. package/lib/catalogo.js +29 -6
  8. package/lib/comandos-extra.js +2 -0
  9. package/lib/config.js +32 -4
  10. package/lib/entrada.js +33 -11
  11. package/lib/estudio/cenarios.js +91 -0
  12. package/lib/estudio/colecao.js +35 -1
  13. package/lib/estudio/diretor.js +1 -0
  14. package/lib/estudio/falantes.js +30 -6
  15. package/lib/estudio/hibrido.js +144 -8
  16. package/lib/estudio/index.js +196 -63
  17. package/lib/estudio/kit/mundo.jsx +1060 -0
  18. package/lib/estudio/kit/voxel.jsx +315 -70
  19. package/lib/estudio/kit.js +1 -1
  20. package/lib/estudio/kokoro.js +18 -4
  21. package/lib/estudio/margem.js +1 -1
  22. package/lib/estudio/movimentos.js +212 -0
  23. package/lib/estudio/mundo.js +328 -0
  24. package/lib/estudio/personagem.js +164 -11
  25. package/lib/estudio/plano.js +6 -0
  26. package/lib/estudio/producao.js +107 -17
  27. package/lib/estudio/projeto.js +77 -22
  28. package/lib/leitura.js +176 -0
  29. package/lib/local/cerebro.json +180 -3
  30. package/lib/local/entrada.js +61 -4
  31. package/lib/mcp.js +296 -12
  32. package/lib/memoria-conversa.js +32 -3
  33. package/lib/mencoes.js +5 -1
  34. package/lib/nome-curto.js +126 -0
  35. package/lib/nome-projeto.js +5 -2
  36. package/lib/outra-pasta.js +161 -0
  37. package/lib/perigo.js +34 -2
  38. package/lib/referencia.js +40 -11
  39. package/lib/repeticao.js +20 -1
  40. package/lib/tela.js +1 -1
  41. package/lib/tools.js +76 -6
  42. package/lib/travado.js +141 -0
  43. package/lib/ui.js +1 -0
  44. package/package.json +2 -2
@@ -28,6 +28,8 @@
28
28
  'use strict';
29
29
 
30
30
  const { volumesDaTrilha } = require('./ritmo.js');
31
+ const mundo = require('./mundo.js');
32
+ const colecao = require('./colecao.js');
31
33
 
32
34
  const FPS = 30;
33
35
  const r2 = (x) => Math.round(x * 100) / 100;
@@ -63,7 +65,7 @@ const CENARIOS_VOXEL = {
63
65
  // O nome antigo do set (podcast, estúdio…) tem um par no voxel.
64
66
  const VOXEL_DO_LISO = { podcast: 'telas', estudio: 'oceano', escritorio: 'terra', noite: 'neon' };
65
67
  // O mascote é laranja (o do Claude) a menos que o roteiro diga a cor.
66
- const CORES_MASCOTE = { claude: '#d97757', gpt: '#10a37f', chatgpt: '#10a37f', gemini: '#4f7cff', primo: '#22b8d8', primocode: '#22b8d8' };
68
+ const CORES_MASCOTE = { claude: '#dd5a2b', gpt: '#10a37f', chatgpt: '#10a37f', gemini: '#4f7cff', primo: '#22b8d8', primocode: '#22b8d8' };
67
69
  const ROUPAS_VOXEL = ['#c2452f', '#2f4cdd', '#1f8a5b', '#d08a1e', '#7b5cff', '#e8e2d6'];
68
70
  // A pele de bloco é um tom mais fundo: sob a luz quente, o tom claro estourava.
69
71
  const PELES_VOXEL = ['#d9a07a', '#c08660', '#a8704c', '#8a5a3c', '#6b4229'];
@@ -84,12 +86,44 @@ function rng(semente) {
84
86
  }
85
87
 
86
88
  /** A aparência de quem fala: o que o roteiro disse vale; o resto sai do nome. */
89
+ /* O TIPO de quem fala (11/10, "se eu pedir um cachorro ele vai fazer"):
90
+ pessoa · robo (o mascote) · animal (uma espécie que o kit/mundo.jsx monta) ·
91
+ coisa (qualquer objeto com ícone 3D, em blocos: "a pizza conversando com o
92
+ café"). O que o roteiro disse vale; senão, o NOME decide. */
93
+ const TIPOS = ['pessoa', 'robo', 'animal', 'coisa'];
94
+ function especieDe(txt) {
95
+ const s = mundo.sem(txt);
96
+ return Object.keys(mundo.ESPECIES).find((k) => mundo.ESPECIES[k].test(s) || k === s) || null;
97
+ }
98
+ function iconeDe(txt) {
99
+ const t = String(txt || '').trim().toLowerCase();
100
+ if (!t) return null;
101
+ return colecao.melhorIcone3d(t, 80) || (colecao.PT_EN[t] || colecao.PT_EN[mundo.sem(t)] ? colecao.melhorIcone3d(t, 40) : null);
102
+ }
103
+ function tipoDe(nome, pedido = {}) {
104
+ if (TIPOS.includes(pedido.tipo)) return pedido.tipo;
105
+ if (pedido.especie) return 'animal';
106
+ if (pedido.icone || pedido.coisa) return 'coisa';
107
+ if (especieDe(nome)) return 'animal';
108
+ if (EH_ROBO.test(nome)) return 'robo';
109
+ if (iconeDe(nome)) return 'coisa';
110
+ return 'pessoa';
111
+ }
112
+
87
113
  function aparencia(nome, pedido = {}) {
88
114
  const k = (s) => hashDe(nome + ':' + s);
89
- const tipo = pedido.tipo === 'robo' || pedido.tipo === 'pessoa' ? pedido.tipo : (EH_ROBO.test(nome) ? 'robo' : 'pessoa');
115
+ const tipo = tipoDe(nome, pedido);
90
116
  const rotulo = String(pedido.rotulo || pedido.nome || nome || '').trim().slice(0, 24);
117
+ if (tipo === 'animal') {
118
+ const especie = especieDe(pedido.especie || '') || especieDe(nome) || 'cachorro';
119
+ return { nome, rotulo, tipo, especie, ...(pedido.cor ? { cor: pedido.cor } : {}), ...(pedido.cor2 ? { cor2: pedido.cor2 } : {}) };
120
+ }
121
+ if (tipo === 'coisa') {
122
+ const termo = String(pedido.icone || pedido.coisa || nome);
123
+ return { nome, rotulo, tipo, icone: termo, ...(pedido.src ? { src: pedido.src } : {}) };
124
+ }
91
125
  if (tipo === 'robo') {
92
- return { nome, rotulo, tipo, cor: pedido.cor || pegar(ROBOS, k('cor')), olhos: pedido.olhos || null };
126
+ return { nome, rotulo, tipo, cor: pedido.cor || pegar(ROBOS, k('cor')), olhos: pedido.olhos || null, ...(pedido.bigode ? { bigode: true } : {}) };
93
127
  }
94
128
  return {
95
129
  nome, rotulo, tipo,
@@ -99,6 +133,9 @@ function aparencia(nome, pedido = {}) {
99
133
  roupa: pedido.roupa || pegar(ROUPAS, k('roupa')),
100
134
  oculos: pedido.oculos != null ? !!pedido.oculos : k('oculos') < 0.3,
101
135
  barba: !!pedido.barba, // só quando pedida: sorteada, ficava estranha em quem tem cabelo longo
136
+ bigode: !!pedido.bigode,
137
+ // A cabeça facetada (low-poly) é o padrão; 'cubo' é o rosto de pixel da 10.5.
138
+ ...(pedido.cabeca === 'cubo' ? { cabeca: 'cubo' } : {}),
102
139
  };
103
140
  }
104
141
 
@@ -119,6 +156,22 @@ function destaqueDe(pt, texto) {
119
156
  return m ? m[1].trim() : null;
120
157
  }
121
158
 
159
+ /* O que o corpo faz na fala, além de falar. O roteiro diz (parte.acao); sem
160
+ ele, o texto sugere: "oi"/"tchau" acena, "olha"/"aqui" aponta, "!" de
161
+ vitória comemora. kit/voxel.jsx e kit/mundo.jsx desenham cada uma. */
162
+ const ACOES = ['falar', 'acenar', 'apontar', 'pular', 'dancar', 'comemorar', 'pensar', 'andar', 'correr', 'sentar'];
163
+ function acaoDe(pt, texto) {
164
+ const a = mundo.sem(pt.acao || '').replace(/[^a-z]/g, '');
165
+ const achada = ACOES.find((x) => a.startsWith(x.slice(0, 5)));
166
+ if (achada) return achada;
167
+ const s = mundo.sem(texto);
168
+ if (/^(oi|ola|e ai|fala,|bom dia|boa tarde|boa noite)\b|\b(tchau|ate mais|ate logo|valeu, ate)\b/.test(s)) return 'acenar';
169
+ if (/\b(consegui|conseguimos|ganhamos|vencemos|incrivel|isso ai|uhu|eba|viva)\b.*!/.test(s)) return 'comemorar';
170
+ if (/\b(olha|olhe|veja|repara|ali|aqui o|esse aqui)\b/.test(s)) return 'apontar';
171
+ if (/\b(sera que|hmm+|deixa eu pensar|pensando)\b/.test(s)) return 'pensar';
172
+ return 'falar';
173
+ }
174
+
122
175
  const falaDe = (pt) => String(pt.texto || pt.fala || pt.narracao || pt.titulo || '').trim();
123
176
 
124
177
  /* Sem os tempos da voz, cada fala ganha o tempo de leitura (2,6 palavras/s)
@@ -206,6 +259,36 @@ function paineisDe(falas, roteiro = {}) {
206
259
  return out.length ? out : [{ titulo: String(roteiro.titulo || 'Agora').slice(0, 18), linha: '' }];
207
260
  }
208
261
 
262
+
263
+ /* BICHO NÃO FALA (11/10, "e o cachorro fala? mds kkk"): fala de bicho com voz
264
+ de gente fica ridícula. A parte do bicho SAI da narração e ele fica na cena
265
+ como figurante (abanando o rabo). Ele só fala se o pedido disser que fala:
266
+ "cachorro falante", "um gato que fala", "o cachorro falando". Muda o
267
+ roteiro no lugar (o array de partes é o mesmo) e devolve quem calou. */
268
+ function calarBichos(roteiro, pedido = '') {
269
+ const partes = Array.isArray(roteiro && roteiro.partes) ? roteiro.partes : [];
270
+ const pedidos = (roteiro && roteiro.personagens) || {};
271
+ const ped = mundo.sem(pedido);
272
+ const calados = [];
273
+ for (let i = partes.length - 1; i >= 0; i--) {
274
+ const pt = partes[i];
275
+ const nome = typeof (pt && pt.falante) === 'string' ? pt.falante.trim().toLowerCase() : '';
276
+ if (!nome) continue;
277
+ const dado = pedidos[nome] || pedidos[Object.keys(pedidos).find((k) => k.toLowerCase() === nome)] || {};
278
+ const fala = mundo.sem(partes.filter((x) => String(x.falante || '').trim().toLowerCase() === nome).map(falaDe).join(' '));
279
+ const latido = /\bau[ -]?au\b|\bau!|\bmiau|\bmu+u\b|\boinc\b|\bquack\b|\bcocoric/.test(fala);
280
+ const tipo = dado.tipo || (dado.especie ? 'animal' : (especieDe(nome) || latido ? 'animal' : null));
281
+ if (tipo !== 'animal' || dado.fala === true) continue;
282
+ const esp = especieDe(dado.especie || '') || especieDe(nome) || (/\bmiau/.test(fala) ? 'gato' : 'cachorro');
283
+ const falante = new RegExp(`\\b(${Object.keys(mundo.ESPECIES).join('|')}|bicho|animal|${nome})\\w*\\s+(falante|que fala|falando|fala)\\b`).test(ped);
284
+ if (falante) continue;
285
+ partes.splice(i, 1);
286
+ if (!calados.some((c) => c.nome === nome)) calados.push({ nome, especie: esp });
287
+ }
288
+ if (calados.length) roteiro.figurantesExtra = [...(roteiro.figurantesExtra || []), ...calados.map((c) => ({ nome: c.nome, tipo: 'animal', especie: c.especie }))];
289
+ return calados;
290
+ }
291
+
209
292
  /** A partitura inteira: o que o kit/personagem.jsx desenha, quadro a quadro. */
210
293
  function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = [] }) {
211
294
  const partes = (roteiro.partes || []).filter((p) => p && typeof p === 'object' && falaDe(p));
@@ -220,9 +303,20 @@ function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = []
220
303
  if (!nomes.includes(f)) nomes.push(f);
221
304
  }
222
305
  const pedidos = roteiro.personagens && typeof roteiro.personagens === 'object' ? roteiro.personagens : {};
223
- const pedidoDe = (n) => pedidos[n] || pedidos[Object.keys(pedidos).find((k) => k.toLowerCase() === n)] || {};
224
- // No set cabem dois; o terceiro em diante divide o lugar (raro num diálogo).
225
- const elenco = nomes.slice(0, 2).map((n, i) => ({ ...aparencia(n, pedidoDe(n)), lado: nomes.length > 1 ? (i === 0 ? -1 : 1) : 0 }));
306
+ /* Quem LATE é cachorro: um falante sem tipo cuja fala tem a voz de um
307
+ bicho ("Au au!", "Miau") vira aquele bicho — o modelo pequeno escreve o
308
+ diálogo e esquece de dizer que o Rex é um cachorro. */
309
+ const VOZ_DE_BICHO = [[/\bau[ -]?au\b|\bau!|\bauau\b|\bwoof\b/, 'cachorro'], [/\bmiau+\b/, 'gato'], [/\bmu+u\b/, 'vaca'], [/\boinc\b/, 'porco'],
310
+ [/\bquack\b|\bqua[ -]?qua\b/, 'pato'], [/\bcocoric[oó]\b/, 'galinha'], [/\bpiu[ -]?piu\b/, 'passaro'], [/\bme+h+\b|\bbe+h+\b/, 'ovelha'], [/\brua+r+\b|\broar\b/, 'leao']];
311
+ const pedidoDe = (n) => {
312
+ const dado = pedidos[n] || pedidos[Object.keys(pedidos).find((k) => k.toLowerCase() === n)] || {};
313
+ if (dado.tipo || dado.especie) return dado;
314
+ const fala = mundo.sem(partes.filter((pt) => String(pt.falante || '').trim().toLowerCase() === n).map(falaDe).join(' '));
315
+ const bicho = VOZ_DE_BICHO.find(([re]) => re.test(fala));
316
+ return bicho ? { ...dado, tipo: 'animal', especie: bicho[1] } : dado;
317
+ };
318
+ // Até QUATRO falam no set ("três amigos no parque"); do quinto em diante dividem o lugar.
319
+ const elenco = nomes.slice(0, 4).map((n, i) => ({ ...aparencia(n, pedidoDe(n)), lado: nomes.length > 1 ? (i < nomes.length / 2 ? -1 : 1) : 0, ordem: i }));
226
320
  const quemDe = (pt) => {
227
321
  const f = typeof pt.falante === 'string' && pt.falante.trim() ? pt.falante.trim().toLowerCase() : 'apresentador';
228
322
  return nomes.indexOf(f) % Math.max(1, elenco.length);
@@ -235,7 +329,7 @@ function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = []
235
329
  .map((w) => ({ t: String(w.texto || w.word || ''), de: r2(w.de), ate: r2(w.ate) }));
236
330
  return {
237
331
  quem: quemDe(pt), de: r2(t.de != null ? t.de : (palavras[0] || {}).de || 0), ate: r2(t.ate != null ? t.ate : (palavras[palavras.length - 1] || {}).ate || 0),
238
- texto, palavras, emocao: emocaoDe(pt, texto), destaque: destaqueDe(pt, texto),
332
+ texto, palavras, emocao: emocaoDe(pt, texto), destaque: destaqueDe(pt, texto), acao: acaoDe(pt, texto),
239
333
  };
240
334
  });
241
335
  const duracao = r2((falas.length ? falas[falas.length - 1].ate : 3) + 1.2);
@@ -258,14 +352,72 @@ function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = []
258
352
  cenarioFinal = { nome: nomeV, ...CENARIOS_VOXEL[nomeV] };
259
353
  if (roteiro.acento && /^#[0-9a-f]{6}$/i.test(roteiro.acento)) cenarioFinal.acento = roteiro.acento;
260
354
  for (const e of elenco) {
261
- if (e.tipo === 'robo' && !(pedidoDe(e.nome) || {}).cor) e.cor = CORES_MASCOTE[e.nome] || '#d97757';
355
+ if (e.tipo === 'robo' && !(pedidoDe(e.nome) || {}).cor) e.cor = CORES_MASCOTE[e.nome] || '#dd5a2b';
262
356
  if (e.tipo === 'pessoa' && !(pedidoDe(e.nome) || {}).roupa) e.roupa = pegar(ROUPAS_VOXEL, hashDe(e.nome + ':roupa'));
263
357
  if (e.tipo === 'pessoa' && !(pedidoDe(e.nome) || {}).pele) e.pele = pegar(PELES_VOXEL, hashDe(e.nome + ':pele'));
264
358
  if (e.tipo === 'pessoa' && !(pedidoDe(e.nome) || {}).corCabelo) e.corCabelo = pegar(CABELOS_VOXEL, hashDe(e.nome + ':corCabelo'));
265
359
  }
266
360
  }
361
+ /* O MUNDO (mundo.js): o lugar e as coisas. roteiro.cena (texto livre ou
362
+ objeto) é o do vídeo; parte.cenario muda o lugar a partir daquela fala.
363
+ O pedido da pessoa e as falas completam o que a cena não disse. */
364
+ const pedidoTexto = String(roteiro.pedido || roteiro.descricao || '');
365
+ const textoTodo = [pedidoTexto, roteiro.titulo, ...falas.map((f) => f.texto)].filter(Boolean).join(' ');
366
+ const cenaBase = roteiro.cena || roteiro.cenario || '';
367
+ const mundos = [];
368
+ const indiceDe = (pedido) => {
369
+ const chave = JSON.stringify(pedido || '');
370
+ const ja = mundos.findIndex((m) => m.chave === chave);
371
+ if (ja >= 0) return ja;
372
+ mundos.push({ chave, ...mundo.mundoDe(pedido, { texto: textoTodo, pedidoTexto, semente: semente + mundos.length * 7, nElenco: elenco.length }) });
373
+ return mundos.length - 1;
374
+ };
375
+ indiceDe(cenaBase);
376
+ let atual = 0;
377
+ partes.forEach((pt, i) => {
378
+ const pedidoParte = pt.cena || pt.cenario;
379
+ if (pedidoParte) atual = indiceDe(pedidoParte);
380
+ if (falas[i]) falas[i].mundo = atual;
381
+ });
382
+ for (const m of mundos) delete m.chave;
383
+ // O set da referência (telas) segue com as cores dele; os outros lugares trazem as suas.
384
+ if (mundos[0].lugar !== 'telas') cenarioFinal = { ...cenarioFinal, nome: mundos[0].nome, parede: mundos[0].parede || mundos[0].ceu?.[1] || cenarioFinal.parede, acento: mundos[0].acento };
385
+
386
+ /* OS FIGURANTES: quem o pedido põe na cena e não fala ("um humano com o
387
+ cachorro", "dois humanos" com um narrador só). roteiro.figurantes manda;
388
+ senão, os bichos e as pessoas do pedido que sobraram do elenco. */
389
+ const figurantes = [];
390
+ if (Array.isArray(roteiro.figurantes)) {
391
+ for (const f of roteiro.figurantes.slice(0, 4)) {
392
+ const pedidoF = typeof f === 'string' ? { tipo: tipoDe(f, {}), especie: especieDe(f) || undefined, icone: f } : f || {};
393
+ const nomeF = typeof f === 'string' ? f : String(f.nome || f.especie || f.icone || 'figurante');
394
+ figurantes.push({ ...aparencia(nomeF, pedidoF) });
395
+ }
396
+ } else if (pedidoTexto) {
397
+ const lido = mundo.lerElenco(pedidoTexto);
398
+ const temEspecie = (esp) => elenco.some((e) => e.tipo === 'animal' && e.especie === esp);
399
+ for (const b of lido.bichos) {
400
+ for (let k = 0; k < b.quantos - (temEspecie(b.especie) ? 1 : 0) && figurantes.length < 3; k++) {
401
+ figurantes.push(aparencia(`${b.especie} ${k + 1}`, { tipo: 'animal', especie: b.especie }));
402
+ }
403
+ }
404
+ const pessoas = elenco.filter((e) => e.tipo === 'pessoa').length;
405
+ for (let k = pessoas; k < lido.pessoas && figurantes.length < 4; k++) figurantes.push(aparencia(`pessoa ${k + 1}`, { tipo: 'pessoa' }));
406
+ }
407
+ // Os bichos que calaram (calarBichos) entram como figurantes, antes de tudo.
408
+ for (const f of (Array.isArray(roteiro.figurantesExtra) ? roteiro.figurantesExtra : []).slice(0, 3)) {
409
+ if (!figurantes.some((x) => x.nome === f.nome)) figurantes.unshift(aparencia(f.nome, { tipo: 'animal', especie: f.especie }));
410
+ }
411
+ // O mesmo bicho que o pedido já pôs como figurante não aparece duas vezes.
412
+ for (let k = figurantes.length - 1; k > 0; k--) if (figurantes.slice(0, k).some((x) => x.tipo === 'animal' && x.especie === figurantes[k].especie && x !== figurantes[k] && /\d$/.test(figurantes[k].nome))) figurantes.splice(k, 1);
413
+ if (visual === 'voxel') {
414
+ for (const e of figurantes) {
415
+ if (e.tipo === 'pessoa') { e.roupa = pegar(ROUPAS_VOXEL, hashDe(e.nome + ':roupa')); e.pele = pegar(PELES_VOXEL, hashDe(e.nome + ':pele')); e.corCabelo = pegar(CABELOS_VOXEL, hashDe(e.nome + ':corCabelo')); }
416
+ }
417
+ }
418
+
267
419
  return {
268
- versao: 1, fps: FPS, largura, altura, duracao, semente, cenario: cenarioFinal, elenco, falas, planos, visual,
420
+ versao: 2, fps: FPS, largura, altura, duracao, semente, cenario: cenarioFinal, elenco, falas, planos, visual, mundos, figurantes,
269
421
  paineis: paineisDe(falas, roteiro),
270
422
  legenda: roteiro.legenda !== false,
271
423
  titulo: typeof roteiro.titulo === 'string' ? roteiro.titulo.slice(0, 60) : '',
@@ -296,8 +448,9 @@ function montarPersonagem({ roteiro, largura = 1920, altura = 1080, tempos = [],
296
448
  };
297
449
  return {
298
450
  ok: true, plano, partitura,
299
- resumo: { visual: partitura.visual, cenas: 1, segundos: DUR, fps: FPS, cenario: partitura.cenario.nome, elenco: partitura.elenco.map((e) => `${e.rotulo} (${e.tipo})`), planos: partitura.planos.length, falas: partitura.falas.length },
451
+ 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})`),
452
+ 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 },
300
453
  };
301
454
  }
302
455
 
303
- module.exports = { montarPersonagem, partituraDe, planosDe, paineisDe, aparencia, destaqueDe, emocaoDe, CENARIOS, CENARIOS_VOXEL, EH_ROBO, FPS };
456
+ module.exports = { calarBichos, montarPersonagem, partituraDe, planosDe, paineisDe, aparencia, tipoDe, acaoDe, destaqueDe, emocaoDe, CENARIOS, CENARIOS_VOXEL, EH_ROBO, FPS };
@@ -49,6 +49,7 @@ const fs = require('fs');
49
49
  const path = require('path');
50
50
  const morfar = require('./morfar.js');
51
51
  const kit = require('./kit.js');
52
+ const movimentos = require('./movimentos.js');
52
53
 
53
54
  // "kit": uma peça pronta do kit de motion (kit.js), pelo nome — ver kit/*.jsx.
54
55
  const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente', 'grupo', 'kit']);
@@ -283,6 +284,11 @@ function conferir(plano, { pasta = null } = {}) {
283
284
  if (!plano || typeof plano !== 'object') {
284
285
  return { ok: false, problemas: [erro('plano', 'o plano é um objeto JSON')], duracao: 0 };
285
286
  }
287
+ /* `movimento: "quicar-de-baixo"` vira quadros-chave (movimentos.js) antes
288
+ de tudo: o que segue confere o `anima` como se a IA o tivesse escrito. */
289
+ for (const d of movimentos.expandir(plano)) {
290
+ problemas.push(erro(d.onde, `o movimento "${d.nome}" não existe — procure com estudio_midia acao:"movimentos" termo:"…"`));
291
+ }
286
292
 
287
293
  const f = plano.formato || {};
288
294
  const largura = Number(f.largura) || 1920;
@@ -22,11 +22,22 @@
22
22
  * provedor nenhum.
23
23
  *
24
24
  * Puro de propósito: a rede e o disco moram nos provedores, então o teste roda
25
- * sem chave e sem internet.
25
+ * sem chave e sem internet. A exceção é o cacheDe(pasta), que guarda o que já
26
+ * foi produzido num projeto.
27
+ *
28
+ * ── UMA NECESSIDADE, UMA CORRENTE ────────────────────────────────────────
29
+ * produzirUma(pedido, provedores, { escolha, cache }) a mesma corrente para
30
+ * a voz, a trilha e os efeitos do index.js. `escolha` (do roteiro:
31
+ * `provedores: { voz: 'kokoro' }`) passa um provedor para a frente.
32
+ * O cache é por necessidade + provedor: trocar a voz só refaz a voz.
26
33
  */
27
34
 
28
35
  'use strict';
29
36
 
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const crypto = require('crypto');
40
+
30
41
  // Quantas fotos cada tipo de parte do cinema costuma consumir.
31
42
  const FOTOS = { montagem: 3, climax: 4, numeros: 2, escada: 2, dia: 1 };
32
43
 
@@ -63,28 +74,107 @@ function necessidades(roteiro, { falaDe = textoPadrao } = {}) {
63
74
  return pedidos;
64
75
  }
65
76
 
77
+ /** O provedor escolhido no roteiro vai para a frente; o resto segue na ordem. */
78
+ function ordenar(provedores, escolha) {
79
+ const lista = (provedores || []).filter(Boolean);
80
+ const i = escolha ? lista.findIndex((pv) => pv.nome === String(escolha).toLowerCase()) : -1;
81
+ return i > 0 ? [lista[i], ...lista.slice(0, i), ...lista.slice(i + 1)] : lista;
82
+ }
83
+
84
+ /** A chave do cache: o que a cena pede (pedido.chave, ou o pedido todo) + quem gera. */
85
+ function chaveDe(pedido, nome) {
86
+ const { destino, ...resto } = pedido || {};
87
+ const quer = resto.chave !== undefined ? [resto.tipo, resto.chave] : resto;
88
+ return crypto.createHash('sha1').update(JSON.stringify([quer, nome])).digest('hex').slice(0, 16);
89
+ }
90
+
91
+ /**
92
+ * Uma necessidade pela corrente: o primeiro provedor que entrega ganha. Erro,
93
+ * null ou { ok: false } cai no próximo; indisponível nem é chamado. Antes de
94
+ * gerar, cada provedor olha o cache dele (a mesma necessidade, o mesmo provedor).
95
+ */
96
+ async function produzirUma(pedido, provedores, { escolha, cache, log = () => {} } = {}) {
97
+ const tentou = [];
98
+ let ultimo = null;
99
+ for (const pv of ordenar(provedores, escolha)) {
100
+ if (pv.disponivel && !pv.disponivel()) continue;
101
+ const chave = chaveDe(pedido, pv.nome);
102
+ const guardado = cache && cache.ler(chave, pedido);
103
+ if (guardado) return { ...pedido, ...guardado, ok: true, quem: pv.nome, doCache: true, tentou };
104
+ let r;
105
+ try {
106
+ r = await pv.gerar(pedido);
107
+ if (r && r.ok !== false) {
108
+ if (cache && !r.naoGuardar) cache.gravar(chave, r);
109
+ return { ...pedido, ...r, ok: true, quem: pv.nome, tentou };
110
+ }
111
+ tentou.push({ nome: pv.nome, motivo: (r && r.error) || 'não devolveu nada' });
112
+ } catch (e) {
113
+ tentou.push({ nome: pv.nome, motivo: (e && e.message) || String(e) });
114
+ }
115
+ if (r) ultimo = r;
116
+ log(`${pedido.tipo}: ${pv.nome} falhou, próximo`);
117
+ }
118
+ return { ...pedido, ...(ultimo || {}), ok: false, error: (ultimo && ultimo.error) || `ninguém produziu ${pedido.tipo}`, tentou };
119
+ }
120
+
66
121
  /** Cada pedido pela corrente dos provedores do tipo dele, na ordem da lista. */
67
- async function produzir(pedidos, provedores, { log = () => {} } = {}) {
122
+ async function produzir(pedidos, provedores, { log = () => {}, escolhas = {}, cache } = {}) {
68
123
  const itens = [];
69
124
  for (const pedido of pedidos || []) {
70
- const tentou = [];
71
- let feito = null;
72
- for (const pv of (provedores || []).filter((x) => x && x.tipo === pedido.tipo)) {
73
- if (pv.disponivel && !pv.disponivel()) continue;
74
- try {
75
- const r = await pv.gerar(pedido);
76
- if (r) { feito = { ...pedido, ...r, ok: true, quem: pv.nome, tentou }; break; }
77
- tentou.push({ nome: pv.nome, motivo: 'não devolveu nada' });
78
- } catch (e) {
79
- tentou.push({ nome: pv.nome, motivo: (e && e.message) || String(e) });
80
- }
81
- log(`${pedido.tipo}: ${pv.nome} falhou, próximo`);
82
- }
83
- itens.push(feito || { ...pedido, ok: false, tentou });
125
+ const daqui = (provedores || []).filter((x) => x && x.tipo === pedido.tipo);
126
+ itens.push(await produzirUma(pedido, daqui, { escolha: escolhas[pedido.tipo], cache, log }));
84
127
  }
85
128
  return { itens, faltou: itens.filter((i) => !i.ok) };
86
129
  }
87
130
 
131
+ /** Os créditos (licença aberta pede atribuição) de tudo o que foi produzido. */
132
+ function creditos(itens) {
133
+ return [...new Set((itens || []).filter((i) => i && i.ok).map((i) => i.credito).filter(Boolean))];
134
+ }
135
+
136
+ /** A linha do caderno: quem produziu cada coisa, e se veio do cache. */
137
+ function linhaCaderno(item) {
138
+ if (!item.ok) return `${item.tipo}: ninguém produziu (${(item.tentou || []).map((t) => t.nome).join(', ') || 'sem provedor'})`;
139
+ const antes = (item.tentou || []).length ? `; antes falhou: ${item.tentou.map((t) => t.nome).join(', ')}` : '';
140
+ return `${item.tipo}: ${item.quem} (${item.doCache ? 'do cache' : 'gerou'}${antes})${item.credito ? ' — ' + item.credito : ''}`;
141
+ }
142
+
143
+ /**
144
+ * O cache de um projeto: midia/producao/cache.json. O arquivo de `r.arquivo`
145
+ * (a voz, que depois é acelerada no lugar) é copiado para lá e volta para o
146
+ * `pedido.destino` no acerto. Caminho "midia/..." que sumiu do disco é erro.
147
+ */
148
+ function cacheDe(pasta) {
149
+ const dir = path.join(pasta, 'midia', 'producao');
150
+ const arq = path.join(dir, 'cache.json');
151
+ const ler = () => { try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return {}; } };
152
+ const caminhos = (v) => (typeof v === 'string' ? (/^midia\//.test(v) ? [v] : [])
153
+ : v && typeof v === 'object' ? Object.values(v).flatMap(caminhos) : []);
154
+ return {
155
+ ler(chave, pedido = {}) {
156
+ const e = ler()[chave];
157
+ if (!e) return null;
158
+ const { arquivoCache, ...r } = e;
159
+ if (caminhos(r).some((c) => !fs.existsSync(path.join(pasta, c)))) return null;
160
+ if (arquivoCache) {
161
+ if (!pedido.destino || !fs.existsSync(path.join(dir, arquivoCache))) return null;
162
+ fs.copyFileSync(path.join(dir, arquivoCache), pedido.destino);
163
+ }
164
+ return r;
165
+ },
166
+ gravar(chave, r) {
167
+ try {
168
+ fs.mkdirSync(dir, { recursive: true });
169
+ const { arquivo, ...resto } = r;
170
+ const e = { ...resto };
171
+ if (arquivo && fs.existsSync(arquivo)) { e.arquivoCache = chave + path.extname(arquivo); fs.copyFileSync(arquivo, path.join(dir, e.arquivoCache)); }
172
+ fs.writeFileSync(arq, JSON.stringify({ ...ler(), [chave]: e }));
173
+ } catch { /* sem cache */ }
174
+ },
175
+ };
176
+ }
177
+
88
178
  /** O resultado no formato que o montarCinema já recebe. */
89
179
  function paraCinema({ itens = [] } = {}) {
90
180
  const ok = itens.filter((i) => i.ok);
@@ -103,4 +193,4 @@ function paraCinema({ itens = [] } = {}) {
103
193
  };
104
194
  }
105
195
 
106
- module.exports = { necessidades, produzir, paraCinema, FOTOS };
196
+ module.exports = { necessidades, produzir, produzirUma, ordenar, chaveDe, creditos, linhaCaderno, cacheDe, paraCinema, FOTOS };
@@ -39,10 +39,13 @@ const os = require('os');
39
39
  const path = require('path');
40
40
  const crypto = require('crypto');
41
41
 
42
+ const { nomeCurto, semColisao } = require('../nome-curto.js');
43
+
42
44
  const CASA = path.join(os.homedir(), '.primocode', 'studio');
43
45
  const PROJETOS = path.join(CASA, 'projetos');
44
46
 
45
- /** Nome de pasta a partir do que a pessoa pediu. */
47
+ /* Nome de ARQUIVO (o mp4, o pdf da saída). A pasta do projeto não usa mais
48
+ isto: ver `criar` e lib/nome-curto.js. */
46
49
  function apelidar(bruto) {
47
50
  return String(bruto || '')
48
51
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
@@ -59,23 +62,64 @@ function garantirCasa() {
59
62
  return PROJETOS;
60
63
  }
61
64
 
62
- /**
63
- * Abre um projeto novo.
65
+ /* ── ONDE MORA CADA PROJETO ──────────────────────────────────────────────
66
+ * Antes: `projetos/2026-10-11-claude-e-humano-conversam-sobre-o-segund`. A
67
+ * data na frente servia para a lista sair em ordem, e o resto era a frase
68
+ * cortada no meio. O dono: "essa estrutura de pastas está horrível".
69
+ *
70
+ * Agora: `projetos/2026-10/claude-segundo-cerebro`. O MÊS vira pasta (a
71
+ * ordem cronológica continua de graça, e um mês cabe numa tela), e o nome é
72
+ * curto e legível (lib/nome-curto.js). O dia exato fica no projeto.json.
64
73
  *
65
- * O id leva a DATA na frente. Não é enfeite: a pasta é listada em ordem
66
- * alfabética por quase tudo que lê disco, e com a data na frente essa ordem
67
- * já é a cronológica — que é como se procura um vídeo ("o de semana
68
- * passada"), não por nome.
74
+ * O id continua sendo SÓ o nome da pasta — sem o mês. É ele que o modelo
75
+ * escreve, que vai na memória e que a pessoa cita; pôr barra nele faria o
76
+ * `pastaDe` recusar (e com razão). Por isso o nome é único entre TODOS os
77
+ * meses, e quem acha a pasta pelo id é o `pastaDe`, que olha os meses.
78
+ *
79
+ * Os projetos antigos (`projetos/2026-09-25-...`) ficam onde estão: nada é
80
+ * renomeado, e o id antigo continua abrindo o mesmo projeto. */
81
+ const MES = /^\d{4}-\d{2}$/;
82
+
83
+ function mesDe(data = new Date()) {
84
+ return `${data.getFullYear()}-${String(data.getMonth() + 1).padStart(2, '0')}`;
85
+ }
86
+
87
+ /** As pastas de mês que existem, da mais nova para a mais velha. */
88
+ function meses() {
89
+ try {
90
+ return fs.readdirSync(PROJETOS, { withFileTypes: true })
91
+ .filter((d) => d.isDirectory() && MES.test(d.name)).map((d) => d.name).sort().reverse();
92
+ } catch { return []; }
93
+ }
94
+
95
+ /** Todos os ids que existem, antigos (soltos) e novos (dentro do mês). */
96
+ function idsTomados() {
97
+ const ids = [];
98
+ try {
99
+ for (const d of fs.readdirSync(PROJETOS, { withFileTypes: true })) {
100
+ if (d.isDirectory() && !MES.test(d.name)) ids.push(d.name);
101
+ }
102
+ } catch { /* casa vazia */ }
103
+ for (const m of meses()) {
104
+ try {
105
+ for (const d of fs.readdirSync(path.join(PROJETOS, m), { withFileTypes: true })) {
106
+ if (d.isDirectory()) ids.push(d.name);
107
+ }
108
+ } catch { /* mês sumiu no meio */ }
109
+ }
110
+ return ids;
111
+ }
112
+
113
+ /**
114
+ * Abre um projeto novo, em `projetos/<AAAA-MM>/<nome-curto>`.
69
115
  */
70
116
  function criar({ titulo, pedido, formato = 'video' }) {
71
117
  garantirCasa();
72
- const dia = new Date().toISOString().slice(0, 10);
73
- const base = `${dia}-${apelidar(titulo || pedido)}`;
74
- let id = base;
75
- let n = 2;
76
- while (fs.existsSync(path.join(PROJETOS, id))) { id = `${base}-${n}`; n++; }
118
+ const texto = titulo || pedido;
119
+ const base = nomeCurto(texto) || 'projeto';
120
+ const id = semColisao(base, idsTomados(), `${titulo || ''} ${pedido || ''}`);
77
121
 
78
- const pasta = path.join(PROJETOS, id);
122
+ const pasta = path.join(PROJETOS, mesDe(), id);
79
123
  for (const sub of ['midia', 'saida', 'entrada']) {
80
124
  fs.mkdirSync(path.join(pasta, sub), { recursive: true });
81
125
  }
@@ -125,7 +169,18 @@ function pastaDe(id) {
125
169
  if (typeof id !== 'string' || !id.trim()) return null;
126
170
  const limpo = id.trim();
127
171
  if (limpo.includes('/') || limpo.includes('\\') || limpo.includes('..')) return null;
128
- return path.join(PROJETOS, limpo);
172
+ // Um mês não é projeto: "2026-10" como id abriria a pasta do mês inteiro.
173
+ if (MES.test(limpo)) return null;
174
+ // O antigo, solto na casa (`2026-09-25-...`), continua valendo.
175
+ const solto = path.join(PROJETOS, limpo);
176
+ if (fs.existsSync(solto)) return solto;
177
+ for (const m of meses()) {
178
+ const dentro = path.join(PROJETOS, m, limpo);
179
+ if (fs.existsSync(dentro)) return dentro;
180
+ }
181
+ // Não existe ainda: o lugar de antes. Quem precisa que exista (`ler`)
182
+ // confere o projeto.json; este caminho só serve para dizer onde seria.
183
+ return solto;
129
184
  }
130
185
 
131
186
  /** O meta de um projeto, ou null. */
@@ -147,14 +202,13 @@ function salvar(id, mudancas) {
147
202
  }
148
203
 
149
204
  /** Todos, do mais novo para o mais velho. */
205
+ /* A ordem vinha do nome (a data na frente). Sem data no nome, ela vem do
206
+ `criado` do projeto.json — que todo projeto, velho ou novo, tem. */
150
207
  function listar({ limite = 50 } = {}) {
151
208
  garantirCasa();
152
- let nomes = [];
153
- try {
154
- nomes = fs.readdirSync(PROJETOS, { withFileTypes: true })
155
- .filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
156
- } catch { return []; }
157
- return nomes.slice(0, limite).map(ler).filter(Boolean);
209
+ const todos = idsTomados().map(ler).filter(Boolean);
210
+ todos.sort((a, b) => String(b.criado || '').localeCompare(String(a.criado || '')) || (a.id < b.id ? 1 : -1));
211
+ return todos.slice(0, limite);
158
212
  }
159
213
 
160
214
  // ── o caderno ────────────────────────────────────────────────────────────
@@ -216,7 +270,8 @@ function procurar(texto, { limite = 5 } = {}) {
216
270
  return { projeto: p, pontos };
217
271
  }).filter((x) => x.pontos > 0);
218
272
 
219
- pontuados.sort((a, b) => b.pontos - a.pontos || (a.projeto.id < b.projeto.id ? 1 : -1));
273
+ pontuados.sort((a, b) => b.pontos - a.pontos
274
+ || String(b.projeto.criado || '').localeCompare(String(a.projeto.criado || '')));
220
275
  return pontuados.slice(0, limite).map((x) => x.projeto);
221
276
  }
222
277
 
@@ -278,7 +333,7 @@ function caminhoDeMidia(id, nome) {
278
333
 
279
334
  module.exports = {
280
335
  CASA, PROJETOS,
281
- criar, ler, salvar, listar, procurar, pastaDe, apelidar,
336
+ criar, ler, salvar, listar, procurar, pastaDe, apelidar, mesDe,
282
337
  anotar, caderno,
283
338
  lerPlano, salvarPlano, versoes, voltarPara,
284
339
  caminhoDeMidia, escreverJSON, lerJSON,