primocode 10.0.9 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -352,7 +352,7 @@ primocode -c # continua a última conversa salva (--co
352
352
 
353
353
  ```json
354
354
  {
355
- "server": "https://primocode-server.onrender.com",
355
+ "server": "https://primocode-server-production.up.railway.app",
356
356
  "model": "main",
357
357
  "token": null
358
358
  }
@@ -361,7 +361,7 @@ primocode -c # continua a última conversa salva (--co
361
361
  Ou por variável de ambiente — elas têm prioridade:
362
362
 
363
363
  ```bash
364
- export PRIMOCODE_SERVER="https://primocode-server.onrender.com"
364
+ export PRIMOCODE_SERVER="https://primocode-server-production.up.railway.app"
365
365
  export PRIMOCODE_MODEL="main"
366
366
  export PRIMOCODE_TOKEN="..." # só se o servidor exigir
367
367
  ```
package/bin/primocode.js CHANGED
@@ -36,6 +36,7 @@ const skills = require('../lib/skills');
36
36
  const checkpoint = require('../lib/checkpoint');
37
37
  const comandosLib = require('../lib/comandos');
38
38
  const marketplace = require('../lib/marketplace');
39
+ const portaoSkill = require('../lib/portao-skill');
39
40
  const vozModo = require('../lib/voz');
40
41
  const conta = require('../lib/conta');
41
42
  const nuvem = require('../lib/nuvem');
@@ -1003,10 +1004,26 @@ async function instalarSkill() {
1003
1004
  console.log('');
1004
1005
  const caminho = String(await ask(' ' + c.brand('❯ ') + c.white('Caminho do SKILL.md: '))).trim();
1005
1006
  if (!caminho) return;
1006
- const r = skills.instalarDe(caminho);
1007
+ const r = await instalarComPortao(caminho);
1007
1008
  console.log(r.ok ? c.ok(` Skill instalada: ${r.nome}`) : c.err(` ${r.error}`));
1008
1009
  }
1009
1010
 
1011
+ /* Instala passando pelo portão (lib/portao-skill.js). Achou algo: mostra cada
1012
+ achado com arquivo:linha e faz a mesma pergunta do que não tem volta
1013
+ (autorizarControle) — Enter sozinho é NÃO. */
1014
+ async function instalarComPortao(caminho) {
1015
+ const r = skills.instalarDe(caminho);
1016
+ if (!r.bloqueada) return r;
1017
+ console.log('');
1018
+ console.log(' ' + c.err(r.error));
1019
+ for (const linha of portaoSkill.descrever(r.achados).slice(0, 20)) console.log(' ' + c.warn(linha));
1020
+ if (r.achados.length > 20) console.log(c.dim(` … e mais ${r.achados.length - 20}`));
1021
+ console.log(' ' + c.dim('Leia cada linha: o portão acha padrão, não intenção.'));
1022
+ const resposta = await autorizarControle(`instalar a skill de ${caminho} mesmo assim`, { tipo: 'perigo' });
1023
+ if (resposta !== 'agora' && resposta !== 'sessao') return { ok: false, error: 'Skill não instalada.' };
1024
+ return skills.instalarDe(caminho, { forcar: true });
1025
+ }
1026
+
1010
1027
  async function sendPrompt(userText) {
1011
1028
  /* O portão do plano. Sem ele o bloqueio da abertura seria só um texto na
1012
1029
  tela: a pessoa leria "você não tem acesso" e mandaria a primeira coisa
@@ -2218,7 +2235,7 @@ async function handleSlash(text) {
2218
2235
  console.log(c.dim(' Ou abra o ') + c.brand('/marketplace') + c.dim(' e escolha "New Skill" para escrever uma.'));
2219
2236
  break;
2220
2237
  }
2221
- const r = skills.instalarDe(rest);
2238
+ const r = await instalarComPortao(rest);
2222
2239
  if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
2223
2240
  console.log(c.ok(` Skill instalada: ${r.nome}`) + c.muted(` (${shortPath(r.caminho)})`));
2224
2241
  console.log(c.dim(' Já está ligada. /skills mostra todas.'));
package/lib/act.js CHANGED
@@ -849,10 +849,13 @@ async function runAct(userPrompt, opts) {
849
849
  * um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
850
850
  if (!subagent && profundidade === 0) {
851
851
  const diretor = require('./estudio/diretor.js');
852
+ // Vídeo que o Estúdio fez se edita no Remotion Studio, não no editor por linha do tempo.
853
+ const EDITAR_DO_ESTUDIO = 'SE ELA QUER EDITAR UM VÍDEO QUE O ESTÚDIO FEZ (não uma gravação dela): '
854
+ + 'estudio_projeto acao:"editar" com o id do projeto abre o Remotion Studio no navegador; diga a pasta a ela e pare.\n';
852
855
  const producao = diretor.precisaDeProducao(pedido);
853
856
  if (diretor.precisaDeEdicao(pedido)) {
854
857
  // A gravação da pessoa: o editor por linha do tempo (lib/video/), pelo run_shell.
855
- messages.push({ role: 'system', content: 'PARA EDITAR A GRAVAÇÃO DA PESSOA use o editor por linha do tempo via run_shell: '
858
+ messages.push({ role: 'system', content: EDITAR_DO_ESTUDIO + 'PARA EDITAR A GRAVAÇÃO DA PESSOA use o editor por linha do tempo via run_shell: '
856
859
  + 'SE A PESSOA SÓ DISSE "edite esse vídeo" (sem dizer o quê), O VÍDEO É O PEDIDO: `primocode video import --json \'{"path":"<arquivo>"}\'`, '
857
860
  + 'depois `primocode video auto --json \'{}\'` (lê o que ela FALA na gravação e aplica cada efeito no momento em que diz), inspect, export e abra o mp4. '
858
861
  + 'Senão: '
@@ -887,7 +890,7 @@ async function runAct(userPrompt, opts) {
887
890
  resposta é o modelo. Então isto é o caminho SE for produzir, e não
888
891
  mais uma ordem de produzir. */
889
892
  messages.push({ role: 'system', content:
890
- 'VOCÊ DECIDE o que entregar, pelo que a pessoa pediu: se ela quer só o roteiro, a ideia, um texto ou uma resposta, ESCREVA isso e pare — não produza vídeo. '
893
+ EDITAR_DO_ESTUDIO + 'VOCÊ DECIDE o que entregar, pelo que a pessoa pediu: se ela quer só o roteiro, a ideia, um texto ou uma resposta, ESCREVA isso e pare — não produza vídeo. '
891
894
  + `SE ela quer a peça pronta (formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}"), faça em três passos: `
892
895
  + `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
893
896
  + 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
package/lib/catalogo.js CHANGED
@@ -77,7 +77,9 @@ const GRUPOS = [
77
77
  resumo: 'vídeo, motion, slides e arte — a IA faz, e no fim abre o resultado pronto',
78
78
  itens: [
79
79
  { tool: 'estudio_projeto', escreve: true, faz: 'abre, acha e lista os projetos do estúdio — cada peça é uma pasta que não some',
80
- args: 'acao: criar | abrir | procurar | listar | anotar. '
80
+ args: 'acao: criar | abrir | procurar | listar | anotar | editar. '
81
+ + 'editar: id (sem id, o mais novo) — quando o usuário quer EDITAR um vídeo que o Estúdio fez, '
82
+ + 'abre o projeto no Remotion Studio (o editor, no navegador) e devolve a pasta. '
81
83
  + 'criar: titulo, pedido, formato (video|slides|arte). '
82
84
  + 'abrir: id — devolve o plano, o CADERNO (o que foi decidido e por quê) e os arquivos. '
83
85
  + 'procurar: texto — acha pelo que o usuário lembra ("aquele do PicPay"), inclusive por detalhe do meio do processo. '
@@ -89,8 +91,9 @@ const GRUPOS = [
89
91
  + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
90
92
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
91
93
  + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). '
94
+ + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
92
95
  + 'estilo: hibrido (PADRÃO: foto e vídeo reais que não param + o motion por cima; dê busca:"termo em inglês" por parte para o b-roll) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; é o do modo sem referências e o de quem pede motion/animação; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
93
- + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | impacto | cinema. '
96
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|lavanda|creme ou tema claro|escuro no roteiro) | impacto | cinema. '
94
97
  + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
95
98
  + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
96
99
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
package/lib/config.js CHANGED
@@ -273,6 +273,14 @@ function podarMemoriaGeral() {
273
273
  } catch {}
274
274
  }
275
275
 
276
+ /* O servidor saiu do Render pro Railway da Paris Group (05/10). Quem gravou o
277
+ endereço antigo no config.json vai pro novo: o Render vai ser desligado. */
278
+ const SERVIDOR = 'https://primocode-server-production.up.railway.app';
279
+ const SERVIDOR_ANTIGO = /^https?:\/\/primocode-server\.onrender\.com\/?$/;
280
+ function servidor(escolhido) {
281
+ return !escolhido || SERVIDOR_ANTIGO.test(String(escolhido).trim()) ? SERVIDOR : escolhido;
282
+ }
283
+
276
284
  function loadConfig() {
277
285
  ensureStructure();
278
286
  let file = {};
@@ -280,7 +288,7 @@ function loadConfig() {
280
288
  if (fs.existsSync(CONFIG_FILE)) file = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
281
289
  } catch { file = {}; }
282
290
  return {
283
- server: process.env.PRIMOCODE_SERVER || file.server || 'https://primocode-server.onrender.com',
291
+ server: servidor(process.env.PRIMOCODE_SERVER || file.server),
284
292
  model: process.env.PRIMOCODE_MODEL || file.model || 'main',
285
293
  // Esforço, estilo Claude Code (/effort). É o que o usuário escolhe; o
286
294
  // modelo é derivado dele. Guardado para durar entre sessões.
@@ -363,7 +371,7 @@ function esquecerProjeto() {
363
371
  module.exports = {
364
372
  tokensDoAssunto, GENERICAS,
365
373
  lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
366
- loadConfig, saveConfig, appendHistory,
374
+ loadConfig, saveConfig, appendHistory, servidor, SERVIDOR,
367
375
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
368
376
  ensureStructure, slugify, stripAccents,
369
377
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
@@ -329,12 +329,18 @@ async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
329
329
  return procurarSom(termo, { quantas, duracaoMin });
330
330
  }
331
331
 
332
- async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
332
+ /* `livre`: só domínio público (CC0 e PDM). O efeito sonoro entra no vídeo sem
333
+ crédito, então não pode ser CC-BY, que o "commercial" deixa passar. */
334
+ function urlSom(termo, { quantas = 8, duracaoMin = 0, livre = false } = {}) {
335
+ let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
336
+ + `&page_size=${quantas}` + (livre ? '&license=cc0,pdm' : '&license_type=commercial');
337
+ if (duracaoMin && duracaoMin < 30) url += '&length=short';
338
+ return url;
339
+ }
340
+
341
+ async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0, livre = false } = {}) {
333
342
  try {
334
- let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
335
- + `&page_size=${quantas}&license_type=commercial`;
336
- if (duracaoMin && duracaoMin < 30) url += '&length=short';
337
- const r = await pegar(url, { json: true });
343
+ const r = await pegar(urlSom(termo, { quantas, duracaoMin, livre }), { json: true });
338
344
  const sons = (r.results || [])
339
345
  .filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
340
346
  .filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
@@ -412,6 +418,6 @@ module.exports = {
412
418
  extensaoPorConteudo,
413
419
  pegar, guardar,
414
420
  icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
415
- procurarImagens, procurarSom, baixar,
421
+ procurarImagens, procurarSom, urlSom, baixar,
416
422
  FONTES, conferirFonte,
417
423
  };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * biblioteca.js — o cardápio de fonte e cor dos estilos de corte (híbrido,
3
+ * cinema, impacto), com memória do que os últimos vídeos usaram.
4
+ *
5
+ * "por falta de variedade a gente tá fazendo os vídeos todos iguais" (Joel, 02/10)
6
+ *
7
+ * Sem fonte e cor da marca, o cinema caía sempre no Inter e no gradiente
8
+ * azul e roxo (#2E81ED → #9567DB), e o híbrido é o padrão: todo vídeo saía
9
+ * com a mesma cara. Aqui a escolha segue esta ordem:
10
+ * 1. a marca (fontes e cores dela) — nunca é trocada;
11
+ * 2. o que a IA escolheu do cardápio (roteiro.visual);
12
+ * 3. um sorteio que pula o que os últimos vídeos já usaram.
13
+ *
14
+ * As 128 famílias foram conferidas no Google Fonts em 02/10, com o peso mais
15
+ * grosso de cada uma (o título do cinema é caixa alta em 800).
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ // ── as fontes: "Nome:peso máximo", por tipo ──────────────────────────────
25
+ const TIPOS = {
26
+ condensada: 'Anton:400, Antonio:700, Archivo Narrow:700, Barlow Condensed:900, Bebas Neue:400, Big Shoulders Display:900, '
27
+ + 'Encode Sans Condensed:900, Fjalla One:400, Khand:700, League Gothic:400, Mohave:700, Oswald:700, Pathway Gothic One:400, '
28
+ + 'Saira Condensed:900, Six Caps:400, Smooch Sans:900, Sofia Sans Condensed:900, Staatliches:400, Teko:700',
29
+ display: 'Alfa Slab One:400, Anybody:900, Archivo Black:400, Archivo:900, Black Ops One:400, Bricolage Grotesque:800, '
30
+ + 'Chakra Petch:700, Chivo:900, Darker Grotesque:900, Epilogue:900, Exo 2:900, Familjen Grotesk:700, Funnel Display:800, '
31
+ + 'Gabarito:900, Lexend:900, Mona Sans:900, Passion One:900, Red Hat Display:900, Righteous:400, Rubik:900, Russo One:400, '
32
+ + 'Schibsted Grotesk:900, Shrikhand:400, Urbanist:900, Wix Madefor Display:800',
33
+ larga: 'Audiowide:400, Bagel Fat One:400, Bowlby One:400, Bungee Shade:400, Bungee:400, Dela Gothic One:400, Krona One:400, '
34
+ + 'Lexend Zetta:900, Michroma:400, Monoton:400, Orbitron:900, Press Start 2P:400, Rubik Mono One:400, Silkscreen:700, Syne:800, Ultra:400',
35
+ texto: 'Afacad:700, Albert Sans:900, Barlow:900, Be Vietnam Pro:900, Figtree:900, Funnel Sans:800, Golos Text:900, '
36
+ + 'Hanken Grotesk:900, Host Grotesk:800, Instrument Sans:700, Karla:800, Onest:900, Public Sans:900, Reddit Sans:900, '
37
+ + 'Rethink Sans:800, Sofia Sans:900, Work Sans:900',
38
+ serifa: 'Abril Fatface:400, Arvo:700, Bitter:900, Bodoni Moda:900, Caprasimo:400, Cormorant Garamond:700, Crimson Pro:900, '
39
+ + 'DM Serif Display:400, EB Garamond:800, Fraunces:900, Gloock:400, Instrument Serif:400, Libre Baskerville:700, '
40
+ + 'Libre Bodoni:700, Libre Caslon Display:400, Literata:900, Lora:700, Merriweather:900, Newsreader:800, Playfair Display:900, '
41
+ + 'Prata:400, Roboto Slab:900, Rozha One:400, Source Serif 4:900, Spectral:800, Yeseva One:400, Young Serif:400, Zilla Slab:700',
42
+ manuscrita: 'Caveat:700, Gloria Hallelujah:400, Kalam:700, Lobster:400, Marck Script:400, Nanum Pen Script:400, Pacifico:400, '
43
+ + 'Permanent Marker:400, Rock Salt:400, Satisfy:400, Shadows Into Light:400, Yellowtail:400',
44
+ mono: 'Azeret Mono:900, DM Mono:400, Fragment Mono:400, Geist Mono:900, IBM Plex Mono:700, JetBrains Mono:800, '
45
+ + 'Major Mono Display:400, Martian Mono:800, Red Hat Mono:700, Space Mono:700, VT323:400',
46
+ };
47
+ const FONTES = Object.entries(TIPOS).flatMap(([tipo, lista]) => lista.split(', ').map((x) => {
48
+ const [nome, peso] = x.split(':');
49
+ return { nome, tipo, peso: Number(peso) };
50
+ }));
51
+
52
+ /* A largura média do caractere, em "em", por tipo — o caber() de
53
+ composicao.js usa para escolher o tamanho. A larga é a que estoura a borda. */
54
+ const LARGURA = { condensada: 0.46, display: 0.6, larga: 0.74, texto: 0.58, serifa: 0.55, manuscrita: 0.52, mono: 0.62 };
55
+ const larguraDe = (fonte) => { const f = acharFonte(fonte); return f ? LARGURA[f.tipo] : null; };
56
+
57
+ /* O título é caixa alta grossa: fonte de texto fina demais não serve. O
58
+ texto (etiquetas, nomes) é qualquer uma que se lê pequena. */
59
+ const TITULO = FONTES.filter((f) => ['condensada', 'display', 'larga'].includes(f.tipo) || (f.tipo === 'serifa' && f.peso >= 800)
60
+ || (f.tipo === 'texto' && f.peso >= 900));
61
+ const TEXTO = FONTES.filter((f) => ['texto', 'mono'].includes(f.tipo) || (f.tipo === 'serifa' && f.peso >= 700) || (f.tipo === 'display' && f.peso >= 800));
62
+
63
+ // ── as paletas: fundo escuro e as duas cores do gradiente ────────────────
64
+ /* O cinema é escuro (o texto é branco), então todo fundo é escuro. Nada do
65
+ violeta de SaaS nem do neon: é o que denuncia vídeo feito por IA. */
66
+ const BASES = [
67
+ ['brasa', '#120B08', '#FF5A1F', '#FFB23F'], ['ferrugem', '#15100D', '#C8553D', '#F2A65A'], ['mata', '#0B1410', '#2BB673', '#D8F05A'],
68
+ ['oceano', '#071219', '#1E88E5', '#4FD1C5'], ['tijolo', '#170C0B', '#E2583E', '#F6D6AD'], ['limao', '#0E100A', '#C6F432', '#F4F1E8'],
69
+ ['cobre', '#120E0C', '#D9822B', '#F3C98B'], ['sangue', '#100607', '#E5383B', '#FF9F1C'], ['musgo', '#0F120C', '#8DB255', '#E9C46A'],
70
+ ['gelo', '#0A0F14', '#8EC5FF', '#F1F7FF'], ['sol-de-inverno', '#0D0D10', '#FFC93C', '#FF6B35'], ['cereja', '#14080B', '#FF4D6D', '#FFC2D1'],
71
+ ['jade', '#06120F', '#00A878', '#9BE564'], ['areia', '#13110D', '#E9C46A', '#F4A261'], ['safira', '#070B16', '#2F6BFF', '#7FB2FF'],
72
+ ['tangerina', '#110A06', '#FF7A00', '#FFD166'], ['pistache', '#0C110D', '#93C572', '#F0EAD6'], ['vinho', '#13070C', '#D1495B', '#FCB9B2'],
73
+ ['petroleo', '#061214', '#0F9D9A', '#F2C14E'], ['ouro-velho', '#0F0D08', '#C9A227', '#F5E6B3'], ['coral', '#120A0A', '#FF6F59', '#FFD6A5'],
74
+ ['asfalto', '#0E0F10', '#FF3B30', '#FFFFFF'], ['menta', '#08110E', '#3DDC97', '#E8FFF4'], ['lava', '#0F0705', '#FF3D00', '#FFB300'],
75
+ ['ceu', '#08101A', '#56C3E8', '#F7F7F7'], ['terracota', '#140D0A', '#CB6843', '#EBD4B4'], ['abacate', '#0D100A', '#A3B18A', '#DAD7CD'],
76
+ ['neve-e-fogo', '#0B0B0E', '#FFFFFF', '#FF4F00'], ['framboesa', '#12070D', '#E0245E', '#FFB3C6'], ['cacau', '#120D0A', '#A47148', '#F2D0A9'],
77
+ ['esmeralda', '#04110B', '#10B981', '#FDE68A'], ['aco', '#0C0E11', '#9DB4C0', '#E0E1DD'], ['acafrao', '#110D05', '#F4B400', '#FF6D00'],
78
+ ['lagoa', '#051214', '#48CAE4', '#CAF0F8'], ['papoula', '#110808', '#F94144', '#F9C74F'], ['oliva', '#0F0F09', '#B5A642', '#F1E9C9'],
79
+ ['ardosia', '#0D1014', '#FF8C42', '#E8EEF2'], ['tinta-e-rosa', '#0E0B0C', '#FF8FAB', '#FFE5EC'], ['hortela-e-ouro', '#08120F', '#52B788', '#F6C453'],
80
+ ['noite-azul', '#060A12', '#3A86FF', '#FFBE0B'], ['cinzas', '#101010', '#D9D9D9', '#FF5F1F'], ['pessego', '#120C0A', '#FFAD87', '#FFE3D3'],
81
+ ['floresta', '#07100A', '#40916C', '#B7E4C7'], ['rubi', '#10060A', '#EF233C', '#EDF2F4'], ['mostarda', '#100E06', '#E1AD01', '#F6F1D1'],
82
+ ['turquesa', '#061111', '#2EC4B6', '#FFBF69'], ['caramelo', '#120C07', '#DDA15E', '#FEFAE0'], ['grafite-limao', '#0F1011', '#D4FF3A', '#F2F2F2'],
83
+ ['alvorada', '#0D0A0C', '#FF9E00', '#FF4D6D'], ['tempestade', '#0A0D10', '#6C9BD2', '#F2E8CF'],
84
+ ];
85
+
86
+ function rgb(hex) { const n = parseInt(String(hex).slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }
87
+ const hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
88
+ const misturar = (a, b, k) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * k));
89
+ const luz = (c) => { const [r, g, b] = rgb(c); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; };
90
+
91
+ /* Cada base vira quatro: como veio, o gradiente invertido, o fundo tingido
92
+ da cor principal e a monocromática (a cor e ela mais clara). */
93
+ const PALETAS = BASES.flatMap(([nome, fundo, a, b]) => [
94
+ { nome, base: nome, fundo, grad: [a, b] },
95
+ { nome: `${nome}-invertida`, base: nome, fundo, grad: [b, a] },
96
+ { nome: `${nome}-tingida`, base: nome, fundo: misturar(fundo, a, 0.1), grad: [a, b] },
97
+ { nome: `${nome}-mono`, base: nome, fundo, grad: [a, misturar(a, '#FFFFFF', 0.45)] },
98
+ ]);
99
+
100
+ // ── a memória: o que os últimos vídeos já usaram ─────────────────────────
101
+ const ARQ = path.join(os.homedir(), '.primocode', 'studio', 'biblioteca-usada.json');
102
+ const LEMBRA = { titulo: 15, texto: 8, paleta: 20 }; // paleta conta pela base: "brasa-mono" depois de "brasa" é repetir
103
+
104
+ function lerMemoria(arq = ARQ) {
105
+ try { const m = JSON.parse(fs.readFileSync(arq, 'utf8')); return { titulo: [], texto: [], paleta: [], ...m }; }
106
+ catch { return { titulo: [], texto: [], paleta: [] }; }
107
+ }
108
+ function gravarMemoria(m, arq = ARQ) {
109
+ try { fs.mkdirSync(path.dirname(arq), { recursive: true }); fs.writeFileSync(arq, JSON.stringify(m)); } catch { /* sem memória, só sorteio */ }
110
+ }
111
+
112
+ const norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
113
+ function acharFonte(nome) { const n = norm(nome); return FONTES.find((f) => norm(f.nome) === n) || null; }
114
+ function acharPaleta(nome) { const n = norm(nome).replace(/\s+/g, '-'); return PALETAS.find((p) => p.nome === n) || null; }
115
+
116
+ /* O sorteio que pula os recentes. Se a lista inteira já foi usada, vale
117
+ qualquer uma — nunca fica sem. */
118
+ function sortear(lista, recentes, chave, sorte) {
119
+ const livres = lista.filter((x) => !recentes.includes(chave(x)));
120
+ const de = livres.length ? livres : lista;
121
+ return de[Math.floor(sorte() * de.length) % de.length];
122
+ }
123
+
124
+ /**
125
+ * Escolhe fonte e paleta para o roteiro e as põe em roteiro.marca, sem
126
+ * trocar nada que a marca já tem. Devolve o que escolheu e de onde veio.
127
+ * memoria: null não lê nem grava o disco (os testes); sorte: () => [0,1).
128
+ */
129
+ function vestir(roteiro, { memoria = ARQ, sorte = Math.random } = {}) {
130
+ if (!roteiro || typeof roteiro !== 'object') return { mudou: false };
131
+ const marca = roteiro.marca || {};
132
+ const cores = marca.cores || {};
133
+ const visual = roteiro.visual || {};
134
+ const mem = memoria ? lerMemoria(memoria) : { titulo: [], texto: [], paleta: [] };
135
+ const escolha = { mudou: false };
136
+
137
+ if (!(marca.fontes && marca.fontes.titulo)) {
138
+ // A IA escolheu pelo nome: vale qualquer família do Google Fonts, mesmo fora do cardápio.
139
+ const titulo = visual.titulo ? String(visual.titulo) : sortear(TITULO, mem.titulo, (f) => f.nome, sorte).nome;
140
+ const textoLista = TEXTO.filter((f) => f.nome !== titulo);
141
+ const texto = visual.texto ? String(visual.texto) : sortear(textoLista, mem.texto, (f) => f.nome, sorte).nome;
142
+ marca.fontes = { ...(marca.fontes || {}), titulo, texto };
143
+ mem.titulo = [titulo, ...mem.titulo.filter((x) => x !== titulo)].slice(0, LEMBRA.titulo);
144
+ mem.texto = [texto, ...mem.texto.filter((x) => x !== texto)].slice(0, LEMBRA.texto);
145
+ Object.assign(escolha, { mudou: true, titulo, texto, fonteDaIA: !!visual.titulo });
146
+ }
147
+ // A cor da marca manda: com destaque ou gradiente declarado, a paleta não entra.
148
+ if (!(marca.gradiente && marca.gradiente.length >= 2) && !cores.destaque) {
149
+ const p = acharPaleta(visual.paleta) || sortear(PALETAS, mem.paleta, (x) => x.base, sorte);
150
+ marca.gradiente = [...p.grad];
151
+ marca.cores = { ...cores, fundo: cores.fundo || p.fundo, destaque: p.grad[1], apoio: p.grad[0] };
152
+ mem.paleta = [p.base, ...mem.paleta.filter((x) => x !== p.base)].slice(0, LEMBRA.paleta);
153
+ Object.assign(escolha, { mudou: true, paleta: p.nome, paletaDaIA: !!acharPaleta(visual.paleta) });
154
+ }
155
+ if (escolha.mudou) {
156
+ roteiro.marca = marca;
157
+ if (memoria) gravarMemoria(mem, memoria);
158
+ }
159
+ return escolha;
160
+ }
161
+
162
+ /** O cardápio para a IA: nomes por tipo, e as paletas pela base. */
163
+ function cardapio() {
164
+ const porTipo = {};
165
+ for (const f of FONTES) (porTipo[f.tipo] = porTipo[f.tipo] || []).push(f.nome);
166
+ return {
167
+ fontes: porTipo,
168
+ paletas: BASES.map(([nome, fundo, a, b]) => `${nome} ${fundo} ${a} ${b}`),
169
+ variacoes: 'cada paleta também vem -invertida, -tingida e -mono (ex.: "brasa-mono")',
170
+ uso: 'roteiro.visual = {titulo, texto, paleta}. Sem visual, o PrimoCode sorteia e não repete os últimos vídeos.',
171
+ };
172
+ }
173
+
174
+ module.exports = { vestir, cardapio, larguraDe, acharFonte, acharPaleta, FONTES, PALETAS, BASES, TITULO, TEXTO, luz, ARQ };
@@ -27,6 +27,7 @@
27
27
  'use strict';
28
28
 
29
29
  const { caber } = require('./composicao.js');
30
+ const { minimoLeitura, sobra } = require('./legibilidade.js');
30
31
 
31
32
  const r2 = (n) => Math.round(n * 100) / 100;
32
33
  const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
@@ -34,6 +35,7 @@ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').t
34
35
  /** Quanto tempo um texto precisa ficar na tela para ser lido. Nunca menos que
35
36
  0,3 s por palavra: a regra de legibilidade do /brag (Arthur, 28/09). */
36
37
  const nPalavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
38
+ const SAIDA_MIN = 0.1, ENTRA_NUM = 0.4; // a saída mais curta; o número contando antes de ser lido
37
39
  const leituraCheia = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
38
40
 
39
41
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
@@ -113,7 +115,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
113
115
  const PRETO = fundoMarca && luz(fundoMarca) < 0.25 ? fundoMarca : '#07070A';
114
116
  const BRANCO = '#F5F5F7';
115
117
  const DEST = grad[1];
116
- const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
118
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter', destaque: 'Instrument Serif' };
117
119
  const W = largura, H = altura, deitado = W >= H;
118
120
  const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
119
121
  const base = Math.min(W, H);
@@ -180,26 +182,36 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
180
182
  fundo: { tipo: 'gradiente', cores: [`rgba(7,7,10,${veu * 0.7})`, `rgba(7,7,10,${veu})`, `rgba(7,7,10,${Math.min(0.95, veu + 0.25)})`], angulo: 180 } });
181
183
  };
182
184
  // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
183
- const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
185
+ /* SEM CARA DE IA (05/10): o texto sai como a pessoa escreveu (nada de caixa
186
+ alta forçada), um pouco menor e com mais respiro — e a palavra marcada é
187
+ UM acento, na serifa em itálico. Caixa alta só com "caixa: true". */
188
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = false, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
189
+ max *= 0.85; peso = Math.min(peso, 700);
184
190
  // Frase longa (a narração na tela) vai em letra normal: em caixa alta ela encolhe e cansa.
185
- if (caixa && String(texto).split(/\s+/).length > 7) { caixa = false; peso = Math.min(peso, 700); }
191
+ if (caixa && String(texto).split(/\s+/).length > 7) caixa = false;
186
192
  // Frase longa ganha linhas (no vertical, até 5), para a letra não encolher.
187
193
  if (String(texto).split(/\s+/).length > 7) linhas = Math.max(linhas, deitado ? 4 : 5);
188
194
  const txt = caixa ? String(texto).toUpperCase() : String(texto);
189
- const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
190
- const h = fit.tamanho * 1.08 * fit.linhas;
195
+ const fit = caber(txt, { largura: larg * 0.78, linhas, max, fonte: F.titulo });
196
+ const h = fit.tamanho * 1.14 * fit.linhas;
191
197
  const ws = txt.split(/\s+/);
192
198
  /* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
193
199
  não foram ditas ocupam espaço invisível e o começo da frase aparece
194
200
  torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
195
201
  const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
196
- const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
197
- els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
202
+ // Uma palavra só, e só em frase de mais de uma palavra (a palavra sozinha não tem com quem contrastar).
203
+ const idx = ws.length < 2 ? [] : ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0).slice(0, 1);
204
+ /* Entra rápido, FICA parado o tempo de ler, e só então sai (a lei do
205
+ /brag). Cena curta encolhe a entrada e a saída, nunca a leitura. */
206
+ const vida = ate - de, ler = minimoLeitura(txt);
207
+ const ent = bater ? sobra(vida - SAIDA_MIN, ler, 0.08, 0.22) : 0, fe = ent / 0.22;
208
+ const saida = r2(sobra(vida - ent, ler, SAIDA_MIN, 0.25));
209
+ els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.14, espacamento: caixa ? 1 : -0.5,
198
210
  x: Math.round(x), y: Math.round(y == null ? (H - h) / 2 : y), largura: Math.round(larg), alinhamento: alinhar, de: r2(de), ate: r2(ate),
199
- revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
200
- anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
211
+ revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST, fonteDestaque: F.destaque } : {}),
212
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: r2(Math.max(0.01, vida - saida)), v: 1 }, { t: r2(vida), v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
201
213
  // O texto que BATE no fundo: entra grande e assenta de uma vez.
202
- ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: 0.12, v: 0.97, e: 'saida' }, { t: 0.22, v: 1 }] } : {}) } });
214
+ ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: r2(0.12 * fe), v: 0.97, e: 'saida' }, { t: r2(ent), v: 1 }] } : {}) } });
203
215
  return h;
204
216
  };
205
217
  const linhaFina = (els, y, de, larg = W * 0.18) => els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - larg) / 2), y: Math.round(y), largura: Math.round(larg), altura: 2,
@@ -242,9 +254,14 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
242
254
  com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
243
255
  a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
244
256
  // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
245
- const minimo = (x) => Math.max(0.3 * nPalavras(x), Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6));
246
- for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
247
- if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
257
+ /* E nunca menos que a lei do /brag (0,8 s parado) mais a saída mínima; o
258
+ número ainda conta antes de ser lido (ENTRA_NUM). */
259
+ const minimo = (x) => Math.max(minimoLeitura(x) + SAIDA_MIN, Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6)) + (p.tipo === 'numeros' ? ENTRA_NUM : 0);
260
+ const espacar = (passo = 0) => {
261
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + Math.max(passo, minimo(textosDaParte[k - 1]))));
262
+ if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + Math.max(leitura(textosDaParte[inicios.length - 1]), minimo(textosDaParte[inicios.length - 1])) + 0.3));
263
+ };
264
+ espacar();
248
265
  const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
249
266
  const palDo = (txt) => { const ws = String(txt).split(/\s+/); const a = pals.findIndex((w) => tokens(w.texto)[0] === tokens(ws[0])[0]); return a < 0 ? [] : ws.map((_, k) => (pals[a + k] ? rel(pals[a + k].de) : null)); };
250
267
 
@@ -256,11 +273,13 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
256
273
  const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
257
274
  let fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
258
275
  const fz = p.tipo === 'climax' ? fortes[i] : null;
276
+ // A virada começa quando as fotos acabam: o texto não perde a leitura para a montagem.
277
+ if (p.tipo === 'climax' && !fz && inicios.length) { inicios[0] = r2(Math.max(inicios[0], fimFotos)); espacar(1.2); }
259
278
  if (fz) {
260
279
  /* A voz forte entra DEPOIS da montagem, junto com o texto que bate. */
261
280
  fimFotos = 1.4;
262
281
  const ini2 = quando(textosDaParte, (fz.palavras || []).map((w) => ({ ...w, de: w.de + fimFotos })), fz.segundos, fimFotos);
263
- ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + 1.2); });
282
+ ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + Math.max(1.2, minimo(textosDaParte[k - 1]))); });
264
283
  dur = r2(Math.max(fimFotos + fz.segundos + 0.5, inicios[inicios.length - 1] + 1.6));
265
284
  const tr = trechos[trechos.length - 1];
266
285
  if (tr && tr.src === fz.src) tr.t = r2(t + fimFotos - 0.05);
@@ -301,9 +320,10 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
301
320
  const larguraNum = String(mv[1] + m[1] + mv[3]).length * 0.8; // serifa larga: 0,62 ainda quebrava
302
321
  const tam = Math.round(Math.min(deitado ? H * 0.24 : W * 0.3, (W0 * 0.96) / Math.max(1, larguraNum)));
303
322
  const casas = /[.,]/.test(m[1]) ? 1 : 0;
323
+ const entN = r2(sobra(fimDe(k) - inicios[k], minimoLeitura(m[1]), 0.3, 0.6));
304
324
  els.push({ tipo: 'texto', texto: mv[1] + m[1] + mv[3], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
305
- de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: 0.6, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
306
- anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
325
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: entN, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
326
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: Math.min(0.25, entN), v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: entN, v: 1, e: 'saida' }] } });
307
327
  pTexto(els, m[2], inicios[k] + 0.1, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
308
328
  });
309
329
  } else if (p.tipo === 'escada') {
@@ -330,7 +350,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
330
350
  pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
331
351
  som(t, 'whoosh', 0.3);
332
352
  const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
333
- els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: 'rgba(149,103,219,0.12)', contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
353
+ els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: `${DEST}1F`, contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
334
354
  x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
335
355
  anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
336
356
  const yT = deitado ? H * 0.5 : H * 0.42;
@@ -71,7 +71,8 @@ const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league go
71
71
  : /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
72
72
 
73
73
  function caber(texto, { largura, linhas = 2, max = 200, min = 18, fonte = 'Inter' }) {
74
- const cw = LARGURA[FAMILIA_TIPO(fonte)];
74
+ // A fonte da biblioteca sabe a própria largura; a de fora, pelo nome.
75
+ const cw = require('./biblioteca.js').larguraDe(fonte) || LARGURA[FAMILIA_TIPO(fonte)];
75
76
  const palavras = String(texto || '').split(/\s+/).filter(Boolean);
76
77
  for (let t = Math.round(max); t >= min; t -= 2) {
77
78
  // Quebra de verdade, palavra por palavra: é o que o navegador fará.
@@ -389,7 +389,8 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
389
389
  tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
390
390
  estilo: {
391
391
  fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
392
- destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
392
+ // Caixa normal (o que a pessoa disse, como disse) e a palavra forte na serifa em itálico.
393
+ destaque: e.destaque, corForte: e.forte, fonteForte: 'Instrument Serif', largura: '92%',
393
394
  },
394
395
  });
395
396
 
@@ -14,6 +14,8 @@
14
14
  * hibrido O PADRÃO do vídeo: o cinema com o kit por cima — foto e vídeo reais
15
15
  * que não param, e o motion por cima (hibrido.js)
16
16
  * composicao a composição antiga, uma cena por parte (composicao.js)
17
+ * fluido minimalista: uma ideia por batida, a forma de uma vira a da próxima,
18
+ * sem corte (fluido.js). É o que "fluido", "minimalista", "clean" e "apple" pedem.
17
19
  *
18
20
  * Quem manda, em ordem: o estilo que o AGENTE pediu; o do roteiro (a menos
19
21
  * que o pedido diga motion); o que o pedido descreve; e o motion.
@@ -21,15 +23,18 @@
21
23
 
22
24
  'use strict';
23
25
 
24
- const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao'];
26
+ const variedade = require('./variedade.js');
27
+
28
+ const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido'];
25
29
  // Nomes que o modelo usa para os mesmos estilos.
26
30
  /* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
27
31
  antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
28
32
  const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
29
33
  tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
30
34
  classico: 'motion', 'clássico': 'motion', 'motion-classico': 'motion', motion3d: 'motion',
31
- absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
35
+ absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
32
36
 
37
+ const PEDE_FLUIDO = /\bfluid[oa]\b|minimalista|\bminimal\b|\bclean\b|\bapple\b/i;
33
38
  const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
34
39
  const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
35
40
  const PEDE_MARCA = /\bbrand|identidade visual|revela[çc][ãa]o d[ae] marca|\blogo(tipo|marca)?\b|vinheta de marca/i;
@@ -43,24 +48,35 @@ const normal = (e) => {
43
48
  return ESTILOS.includes(n) ? n : null;
44
49
  };
45
50
 
46
- function escolherEstilo({ pedido = '', agente = null, roteiro = null } = {}) {
51
+ /* O RODÍZIO: quando ninguém escolheu ("vários estilos... que não sejam padrão
52
+ da IA", Joel 05/10), o padrão não é sempre o híbrido — gira entre os que
53
+ servem a qualquer roteiro, sem repetir os últimos (memória 'estilo'). */
54
+ const RODIZIO = ['hibrido', 'cinema', 'gerativo', 'impacto', 'fluido'];
55
+
56
+ function estiloDaVez(aleatorio = Math.random) {
57
+ return variedade.sortear(RODIZIO, 'estilo', { aleatorio }) || 'hibrido';
58
+ }
59
+
60
+ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, aleatorio } = {}) {
47
61
  const pedidoAgente = normal(agente);
48
62
  if (pedidoAgente) return pedidoAgente;
49
63
  const doRoteiro = normal(roteiro);
50
64
  /* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
51
65
  do lançamento, que são leiaute fixo ("não pode ter template", 30/09). */
52
- if (doRoteiro && !(doRoteiro !== 'gerativo' && PEDE_MOTION.test(pedido))) return doRoteiro;
66
+ if (doRoteiro && !(doRoteiro !== 'gerativo' && doRoteiro !== 'fluido' && PEDE_MOTION.test(pedido))) return doRoteiro;
67
+ // O fluido é um jeito de motion: "motion minimalista" é o fluido, não o gerativo.
68
+ if (PEDE_FLUIDO.test(pedido)) return 'fluido';
53
69
  if (PEDE_MOTION.test(pedido)) return 'gerativo';
54
70
  if (PEDE_VOX.test(pedido)) return 'vox';
55
71
  if (PEDE_MARCA.test(pedido)) return 'marca';
56
72
  if (PEDE_LANCAMENTO.test(pedido)) return 'lancamento';
57
73
  /* Anúncio, reels e o resto: o HÍBRIDO. "Um mix da versão antiga (o reels
58
74
  da imersão, no cinema) e da última (o kit) — absurdo, que nem parece
59
- que foi IA" (29/09). O impacto segue existindo, se pedido pelo nome. */
60
- return 'hibrido';
75
+ que foi IA" (29/09). Com `girar`, o padrão roda (RODIZIO). */
76
+ return girar ? estiloDaVez(aleatorio) : 'hibrido';
61
77
  }
62
78
 
63
79
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
64
- const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento']);
80
+ const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
65
81
 
66
- module.exports = { escolherEstilo, ESTILOS, LEVES, PEDE_MOTION };
82
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO };