primocode 9.8.0 → 9.10.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
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.8.0-beta.23
1
+ # PrimoCode v9.10.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.8.0-beta.23
12
+ ▐▛███▜▌ Primo Code v9.10.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -31,8 +31,10 @@ const { caber } = require('./composicao.js');
31
31
  const r2 = (n) => Math.round(n * 100) / 100;
32
32
  const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
33
33
 
34
- /** Quanto tempo um texto precisa ficar na tela para ser lido. */
35
- const leitura = (t) => 0.55 + 0.22 * String(t || '').split(/\s+/).filter(Boolean).length;
34
+ /** Quanto tempo um texto precisa ficar na tela para ser lido. Nunca menos que
35
+ 0,3 s por palavra: a regra de legibilidade do /brag (Arthur, 28/09). */
36
+ const nPalavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
37
+ const leitura = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
36
38
 
37
39
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
38
40
  /* A voz lê EXATAMENTE o que está na tela, na ordem da tela ("a narração tem
@@ -229,7 +231,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
229
231
  com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
230
232
  a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
231
233
  // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
232
- const minimo = (x) => Math.min(leitura(x), String(x).split(/\s+/).length <= 3 ? 0.75 : 1.6);
234
+ const minimo = (x) => Math.max(0.3 * nPalavras(x), Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6));
233
235
  for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
234
236
  if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
235
237
  const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
@@ -145,13 +145,36 @@ function forteDe(g) {
145
145
  return forte;
146
146
  }
147
147
 
148
+ /* O B-ROLL EM QUATRO ROTAS (Arthur, do b-roll-finder, 28/09): a frase diz qual
149
+ imagem pede, na ordem abaixo. A primeira que casa decide.
150
+ marca a frase diz o nome da marca → a logo / o site da marca
151
+ numero a frase tem número → sem foto: o número já é a imagem
152
+ concreto uma palavra tem foto no acervo → a foto de estoque dela
153
+ pessoa ideia abstrata → fica no rosto de quem fala
154
+ `palavra` é onde o corte se ancora: ele entra 0,2 s DEPOIS dela. */
155
+ const DEPOIS_DA_PALAVRA = 0.2;
156
+
157
+ function rotaDeApoio(g, { marca = null } = {}) {
158
+ const ws = g || [];
159
+ const nomes = (Array.isArray(marca) ? marca : [marca]).filter(Boolean).map((m) => String(m).split(/\s+/).map(limpa));
160
+ for (const nome of nomes) {
161
+ const k = ws.findIndex((_, i) => nome.every((n, j) => ws[i + j] && limpa(ws[i + j].texto) === n));
162
+ if (k >= 0) return { rota: 'marca', termo: null, palavra: ws[k] };
163
+ }
164
+ const num = ws.find((p) => /\d/.test(p.texto));
165
+ if (num) return { rota: 'numero', termo: null, palavra: num };
166
+ let conc = null;
167
+ for (const p of ws) if (termoDeApoio(p.texto) && (!conc || peso(p.texto) > peso(conc.texto))) conc = p;
168
+ if (conc) return { rota: 'concreto', termo: termoDeApoio(conc.texto), palavra: conc };
169
+ return { rota: 'pessoa', termo: null, palavra: null };
170
+ }
171
+
148
172
  /** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
149
- function termosDeApoio(palavras) {
173
+ function termosDeApoio(palavras, opcoes) {
150
174
  const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
151
175
  const termos = [];
152
176
  frases(lista).forEach((g, i) => {
153
- const f = forteDe(g);
154
- const t = f && temApoio(i) ? termoDeApoio(f.texto) : null;
177
+ const t = temApoio(i) ? rotaDeApoio(g, opcoes).termo : null;
155
178
  if (t && !termos.includes(t)) termos.push(t);
156
179
  });
157
180
  return termos;
@@ -231,7 +254,7 @@ const ESTILOS = {
231
254
  // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
232
255
  const temRecorte = (i) => i % 4 === 2;
233
256
 
234
- function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {} }) {
257
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {}, marca = null }) {
235
258
  const e = ESTILOS[estilo] || ESTILOS.viral;
236
259
  const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
237
260
  if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
@@ -290,9 +313,11 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
290
313
  /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
291
314
  que está sendo dito, com um zoom lento, e a legenda continua por
292
315
  cima. É o "b-roll" das edições de videomaker. */
293
- const apoio = forte && e.apoio && temApoio(i) && !rec ? imagens[termoDeApoio(forte.texto)] : null;
316
+ const r = e.apoio && temApoio(i) && !rec ? rotaDeApoio(g, { marca }) : null;
317
+ const apoio = !r ? null : r.rota === 'concreto' ? imagens[r.termo] : r.rota === 'marca' ? imagens.marca : null;
294
318
  if (apoio) {
295
- const de = local(forte.de - 0.1);
319
+ // O corte entra DEPOIS da palavra, nunca antes: quem ouve "cachorro" vê o cachorro.
320
+ const de = local(r.palavra.de + DEPOIS_DA_PALAVRA);
296
321
  const vida = Math.min(2.2, dur - de);
297
322
  if (vida > 0.8) {
298
323
  // O apoio é foto ou vídeo (o Pexels dá os dois; vídeo é melhor).
@@ -447,4 +472,4 @@ async function prepararSons(pasta, video) {
447
472
  return sons;
448
473
  }
449
474
 
450
- module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
475
+ module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ende.js — trilha da ende.app: CC BY 4.0, uso comercial, sem Content ID.
3
+ *
4
+ * "Usar músicas CC BY 4.0 sem Content ID, da ende.app." — Arthur, do
5
+ * /brag (28/09/2026)
6
+ *
7
+ * Por que esta fonte: música do Openverse com licença comercial às vezes está
8
+ * registrada no Content ID, e o vídeo do cliente leva reivindicação no
9
+ * YouTube. A página da ende.app diz: "Important: NO Content ID. [...]
10
+ * Everything is under CC BY 4.0. [...] Commercial use is allowed as well."
11
+ * O crédito é opcional ("Attribution is nice but no longer required"), mas
12
+ * gravamos mesmo assim: custa uma linha e protege o cliente.
13
+ *
14
+ * ── SEM API ──────────────────────────────────────────────────────────────
15
+ * A ende.app não tem API, e a busca por texto (?q=) não filtra: devolve
16
+ * sempre a mesma lista. Então lemos os cartões da listagem, que trazem
17
+ * `musicPlayer(id, 'mp3', duração)`, o título, a página e as tags. A escolha é
18
+ * pelas tags e pela duração. `robots.txt` só fecha /user-api/ e /challenge.
19
+ *
20
+ * Se a página mudar, `lerLista` devolve [] e o provedor devolve null, e a
21
+ * corrente do producao.js cai no próximo provedor (o Openverse). A rede e o
22
+ * disco são injetados: o teste roda com um recorte do HTML real.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const LISTA = 'https://ende.app/en/search'; // 25 cartões; a /en mostra 11
31
+ const CREDITO = 'Música: Sascha Ende (ende.app), CC BY 4.0';
32
+
33
+ // O clima do roteiro (pt) → palavras que aparecem nas tags da ende.app (en).
34
+ const CLIMAS = {
35
+ emocional: ['emotional', 'acoustic', 'atmospheric', 'piano', 'cinematic', 'ambient', 'calm', 'strings'],
36
+ cinema: ['cinematic', 'epic', 'orchestral', 'atmospheric', 'emotional', 'strings'],
37
+ energetico: ['energetic', 'upbeat', 'beat', 'drums', 'rock', 'arcade', 'breakcore', 'epic'],
38
+ viral: ['upbeat', 'beat', 'energetic', 'pop', 'electronic'],
39
+ impacto: ['epic', 'drums', 'trailer', 'energetic', 'beat'],
40
+ neutro: ['background', 'corporate', 'chilled', 'calm', 'ambient'],
41
+ // Produto e tecnologia: pulso de sintetizador, limpo, com impulso.
42
+ tecnologia: ['arpeggiator', 'analog synths', 'clockwork', 'rhythm', 'commercial', 'clean mix', 'crisp'],
43
+ };
44
+
45
+ const limpar = (s) => String(s || '').replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&#0?39;/g, "'").replace(/\s+/g, ' ').trim();
46
+
47
+ /** Os cartões de música da listagem. Página diferente do esperado: []. */
48
+ function lerLista(html) {
49
+ const texto = String(html || '');
50
+ const cortes = [...texto.matchAll(/musicPlayer\(\s*(\d+)\s*,\s*'([^']+\.mp3)'\s*,\s*([\d.]+)/g)];
51
+ return cortes.map((m, k) => {
52
+ const fim = k + 1 < cortes.length ? cortes[k + 1].index : texto.length;
53
+ const cartao = texto.slice(m.index, fim);
54
+ const link = cartao.match(/<a href="([^"]+)" class="ap-title-link">([\s\S]*?)<\/a>/);
55
+ const tags = [...cartao.matchAll(/class="ap-subline-tag">([^<]+)</g)].map((t) => limpar(t[1]).toLowerCase());
56
+ return {
57
+ id: Number(m[1]), mp3: m[2], segundos: Number(m[3]),
58
+ titulo: link ? limpar(link[2]) : null, pagina: link ? link[1] : null, tags,
59
+ };
60
+ }).filter((f) => f.titulo && f.segundos > 0);
61
+ }
62
+
63
+ /** A faixa que cobre o vídeo e mais combina com o clima. Nenhuma cobre: null. */
64
+ function escolher(faixas, { clima = 'neutro', segundos = 0 } = {}) {
65
+ const palavras = CLIMAS[clima] || CLIMAS.neutro;
66
+ const nota = (f) => f.tags.reduce((n, t) => n + palavras.filter((p) => t.includes(p)).length, 0);
67
+ const cabem = (faixas || []).filter((f) => f.segundos >= segundos);
68
+ if (!cabem.length) return null;
69
+ return cabem.reduce((a, b) => (nota(b) > nota(a) ? b : a));
70
+ }
71
+
72
+ async function buscarPadrao(url) {
73
+ const r = await fetch(url, { headers: { 'User-Agent': 'PrimoCode-Estudio' } });
74
+ if (!r.ok) throw new Error(`ende.app respondeu ${r.status}`);
75
+ return r.text();
76
+ }
77
+
78
+ /** Baixa na pasta de mídia e grava o .json da licença ao lado, como o acervo faz. */
79
+ function baixarEm(pasta) {
80
+ return async (url, nome, meta) => {
81
+ const r = await fetch(url);
82
+ if (!r.ok) throw new Error(`download da ende.app respondeu ${r.status}`);
83
+ const destino = path.join(pasta, nome);
84
+ fs.mkdirSync(pasta, { recursive: true });
85
+ fs.writeFileSync(destino, Buffer.from(await r.arrayBuffer()));
86
+ fs.writeFileSync(destino + '.json', JSON.stringify(meta, null, 2));
87
+ return destino;
88
+ };
89
+ }
90
+
91
+ /** O provedor de `musica` para o producao.js. */
92
+ function provedor({ buscar = buscarPadrao, baixar, pasta = 'midia' } = {}) {
93
+ const salvar = baixar || baixarEm(pasta);
94
+ return {
95
+ nome: 'ende', tipo: 'musica',
96
+ async gerar({ clima, segundos = 0 } = {}) {
97
+ const faixa = escolher(lerLista(await buscar(LISTA)), { clima, segundos });
98
+ if (!faixa) return null;
99
+ const meta = {
100
+ fonte: faixa.pagina, titulo: faixa.titulo, autor: 'Sascha Ende',
101
+ licenca: 'CC BY 4.0', contentId: false, credito: CREDITO,
102
+ };
103
+ const src = await salvar(faixa.mp3, `ende-${faixa.id}.mp3`, meta);
104
+ return { src, segundos: faixa.segundos, ...meta };
105
+ },
106
+ };
107
+ }
108
+
109
+ module.exports = { lerLista, escolher, provedor, CLIMAS, CREDITO };
@@ -45,6 +45,9 @@ const composicao = require('./composicao.js');
45
45
  const acervoSite = require('./acervo-site.js');
46
46
  const impacto = require('./impacto.js');
47
47
  const cinema = require('./cinema.js');
48
+ const motion = require('./motion.js');
49
+ const sonsLeves = require('./sons-leves.js');
50
+ const ende = require('./ende.js');
48
51
 
49
52
  const PY = path.join(__dirname, '..', '..', 'studio');
50
53
 
@@ -242,6 +245,8 @@ async function garantirAcervo(p) {
242
245
  /* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
243
246
  (nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
244
247
  numa chamada só, com uma tarefa só. */
248
+ const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
249
+
245
250
  async function roteiroDoServidor(p, args) {
246
251
  const cfg = require('../config').loadConfig();
247
252
  const api = require('../api');
@@ -254,7 +259,8 @@ async function roteiroDoServidor(p, args) {
254
259
  fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
255
260
  site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
256
261
  rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
257
- estilo: args.estilo || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
262
+ // Pedido de motion nunca vira impacto, nem sendo anúncio.
263
+ estilo: args.estilo || (!PEDE_MOTION.test(pedido) && /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedido) ? 'impacto' : undefined),
258
264
  }, cfg.token);
259
265
  if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
260
266
  return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
@@ -916,11 +922,20 @@ async function prepararRoteiro(p, args) {
916
922
  if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
917
923
  } else if (p.marca) roteiro.marca = p.marca;
918
924
  const partes = roteiro.partes || [];
919
- /* O ESTILO: "impacto" (anúncio, corte a cada batida) ou "limpo" (uma cena
920
- por parte). Pedido de anúncio, reels, divulgação ou venda já vem em
921
- impacto — é o que se espera de uma peça dessas em 2026. */
922
- const estilo = args.estilo || roteiro.estilo
923
- || (/an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(`${p.pedido || ''} ${args.pedido || ''}`) ? 'impacto' : 'limpo');
925
+ /* O ESTILO. O padrão é MOTION (motion.js): um plano contínuo em 3D, com
926
+ um protagonista que se deforma de parte em parte — "isso sim é motion"
927
+ (Joel, 28/09). "limpo" era o nome antigo do padrão e vira motion
928
+ também. Anúncio, reels e venda vão para o "impacto" (corte a cada
929
+ batida), a menos que o pedido diga motion ou animação. A composição
930
+ antiga, uma cena por parte, só com estilo: "composicao". */
931
+ const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
932
+ // O estilo que o AGENTE pediu vale; o que o roteirista pôs no roteiro não
933
+ // passa por cima de um pedido de motion.
934
+ const pedido0 = args.estilo || (PEDE_MOTION.test(pedidoTxt) ? null : roteiro.estilo);
935
+ const estilo = pedido0 === 'limpo' ? 'motion' : (pedido0
936
+ || (PEDE_MOTION.test(pedidoTxt) ? 'motion'
937
+ : /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i.test(pedidoTxt) ? 'impacto' : 'motion'));
938
+ const ehMotion = estilo === 'motion';
924
939
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
925
940
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
926
941
 
@@ -1131,8 +1146,17 @@ async function prepararRoteiro(p, args) {
1131
1146
  tempos = v.partes;
1132
1147
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1133
1148
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1134
- sons = await edicao.prepararSons(p.pasta, video);
1135
- try {
1149
+ // O motion usa os efeitos leves; os estilos de corte, os cheios.
1150
+ sons = ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
1151
+ // A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
1152
+ if (ehMotion) {
1153
+ try {
1154
+ const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
1155
+ const m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1156
+ if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
1157
+ } catch { /* cai na busca de sempre */ }
1158
+ }
1159
+ if (!trilha) try {
1136
1160
  const b = await assets.procurarMusica(roteiro.musica || args.trilha || (estilo === 'impacto' ? 'epic energetic' : 'inspiring corporate'), { quantas: 6, duracaoMin: 45 });
1137
1161
  for (const item of (b.ok ? b.sons : [])) {
1138
1162
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
@@ -1156,6 +1180,11 @@ async function prepararRoteiro(p, args) {
1156
1180
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
1157
1181
  return r;
1158
1182
  }
1183
+ if (ehMotion && modo === 'video') {
1184
+ const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1185
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
1186
+ return r;
1187
+ }
1159
1188
  return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
1160
1189
  }
1161
1190
 
@@ -1435,5 +1464,5 @@ async function ferramentaSite(args) {
1435
1464
  module.exports = {
1436
1465
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
1437
1466
  ferramentaVideo, ferramentaRender, ferramentaSite,
1438
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
1467
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,
1439
1468
  };
@@ -0,0 +1,191 @@
1
+ /**
2
+ * morfar.js — uma forma que vira outra, sem corte.
3
+ *
4
+ * "Uma bolinha viaja, aparece um fogo atrás, ela vai se deformando e se
5
+ * transforma no foguete." — Joel, sobre o que separa motion de slide
6
+ * (28/09/2026)
7
+ *
8
+ * O "morph" antigo era uma transição entre cenas (esmaece e desfoca). Isto é
9
+ * a deformação DE VERDADE: a `forma: "traco"` tem um `caminho` (o `d` de um
10
+ * SVG), e o `caminho` anima por quadro-chave como qualquer número. Entre dois
11
+ * caminhos, cada contorno é reamostrado no mesmo número de pontos, alinhado
12
+ * (mesmo sentido, mesmo ponto de partida) e interpolado ponto a ponto.
13
+ *
14
+ * O alinhamento é o que faz a bolinha "esticar" em foguete em vez de girar e
15
+ * se embolar no meio: sem ele, o ponto 0 de uma forma vai para o outro lado da
16
+ * outra.
17
+ *
18
+ * Aceita M, L, H, V, C, S, Q, T e Z (maiúsculo e minúsculo). Arco (A) não:
19
+ * escreva a curva com C. Só o primeiro contorno do caminho conta.
20
+ *
21
+ * Puro e sem dependência. O MESMO código vai para o projeto Remotion
22
+ * (fonteEsm), então o teste do Node prova o que o render desenha.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ // ── embutido: daqui até o fim do bloco vai para src/morfar.js do Remotion ──
28
+ const PONTOS_PADRAO = 120;
29
+ const PASSOS_CURVA = 24;
30
+ const _memo = new Map();
31
+
32
+ function lerComandos(d) {
33
+ const partes = String(d || '').match(/[a-df-z]|-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?/gi) || [];
34
+ const cmds = [];
35
+ let atual = null;
36
+ for (const p of partes) {
37
+ if (/[a-z]/i.test(p) && !/^e/i.test(p)) {
38
+ if (/a/i.test(p)) throw new Error('arco (A) não é aceito no caminho: desenhe a curva com C');
39
+ atual = { c: p, n: [] };
40
+ cmds.push(atual);
41
+ } else if (atual) atual.n.push(Number(p));
42
+ }
43
+ return cmds;
44
+ }
45
+
46
+ /** O primeiro contorno do caminho, como uma linha de pontos. */
47
+ function contorno(d) {
48
+ const cmds = lerComandos(d);
49
+ const pts = [];
50
+ let x = 0, y = 0, x0 = 0, y0 = 0, cx = null, cy = null, qx = null, qy = null;
51
+ const curvaC = (x1, y1, x2, y2, x3, y3) => {
52
+ for (let i = 1; i <= PASSOS_CURVA; i++) {
53
+ const t = i / PASSOS_CURVA, u = 1 - t;
54
+ pts.push([u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3,
55
+ u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3]);
56
+ }
57
+ cx = x2; cy = y2; x = x3; y = y3;
58
+ };
59
+ const curvaQ = (x1, y1, x2, y2) => {
60
+ for (let i = 1; i <= PASSOS_CURVA; i++) {
61
+ const t = i / PASSOS_CURVA, u = 1 - t;
62
+ pts.push([u * u * x + 2 * u * t * x1 + t * t * x2, u * u * y + 2 * u * t * y1 + t * t * y2]);
63
+ }
64
+ qx = x1; qy = y1; x = x2; y = y2;
65
+ };
66
+ let fechou = false;
67
+ for (const { c, n } of cmds) {
68
+ if (fechou) break;
69
+ const rel = c === c.toLowerCase();
70
+ const C = c.toUpperCase();
71
+ const ox = () => (rel ? x : 0), oy = () => (rel ? y : 0);
72
+ if (C !== 'C' && C !== 'S') { cx = null; cy = null; }
73
+ if (C !== 'Q' && C !== 'T') { qx = null; qy = null; }
74
+ if (C === 'Z') { fechou = pts.length > 0; x = x0; y = y0; continue; }
75
+ const passo = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2 }[C];
76
+ if (!passo) continue;
77
+ for (let i = 0; i + passo <= n.length; i += passo) {
78
+ const a = n.slice(i, i + passo);
79
+ if (C === 'M') {
80
+ if (i === 0 && pts.length) { fechou = true; break; }
81
+ x = a[0] + ox(); y = a[1] + oy();
82
+ if (i === 0) { x0 = x; y0 = y; }
83
+ pts.push([x, y]);
84
+ } else if (C === 'L') { x = a[0] + ox(); y = a[1] + oy(); pts.push([x, y]); }
85
+ else if (C === 'H') { x = a[0] + (rel ? x : 0); pts.push([x, y]); }
86
+ else if (C === 'V') { y = a[0] + (rel ? y : 0); pts.push([x, y]); }
87
+ else if (C === 'C') { const [bx, by] = [ox(), oy()]; curvaC(a[0] + bx, a[1] + by, a[2] + bx, a[3] + by, a[4] + bx, a[5] + by); }
88
+ else if (C === 'S') {
89
+ const [bx, by] = [ox(), oy()];
90
+ const x1 = cx == null ? x : 2 * x - cx, y1 = cy == null ? y : 2 * y - cy;
91
+ curvaC(x1, y1, a[0] + bx, a[1] + by, a[2] + bx, a[3] + by);
92
+ } else if (C === 'Q') { const [bx, by] = [ox(), oy()]; curvaQ(a[0] + bx, a[1] + by, a[2] + bx, a[3] + by); }
93
+ else if (C === 'T') {
94
+ const x1 = qx == null ? x : 2 * x - qx, y1 = qy == null ? y : 2 * y - qy;
95
+ curvaQ(x1, y1, a[0] + ox(), a[1] + oy());
96
+ }
97
+ }
98
+ }
99
+ if (pts.length < 2) throw new Error('o caminho precisa de pelo menos dois pontos');
100
+ return pts;
101
+ }
102
+
103
+ /** `n` pontos igualmente espaçados ao longo do contorno fechado. */
104
+ function reamostrar(pts, n) {
105
+ const fechado = [...pts, pts[0]];
106
+ const acum = [0];
107
+ for (let i = 1; i < fechado.length; i++) {
108
+ acum.push(acum[i - 1] + Math.hypot(fechado[i][0] - fechado[i - 1][0], fechado[i][1] - fechado[i - 1][1]));
109
+ }
110
+ const total = acum[acum.length - 1] || 1;
111
+ const saida = [];
112
+ let j = 0;
113
+ for (let k = 0; k < n; k++) {
114
+ const alvo = (k / n) * total;
115
+ while (j < acum.length - 2 && acum[j + 1] < alvo) j++;
116
+ const seg = acum[j + 1] - acum[j] || 1;
117
+ const t = (alvo - acum[j]) / seg;
118
+ saida.push([fechado[j][0] + (fechado[j + 1][0] - fechado[j][0]) * t, fechado[j][1] + (fechado[j + 1][1] - fechado[j][1]) * t]);
119
+ }
120
+ return saida;
121
+ }
122
+
123
+ function area(pts) {
124
+ let s = 0;
125
+ for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; s += a[0] * b[1] - b[0] * a[1]; }
126
+ return s / 2;
127
+ }
128
+
129
+ function pontos(d, n = PONTOS_PADRAO) {
130
+ const chave = n + '|' + d;
131
+ if (!_memo.has(chave)) _memo.set(chave, reamostrar(contorno(d), n));
132
+ return _memo.get(chave);
133
+ }
134
+
135
+ /** Os dois contornos no mesmo sentido e com o ponto de partida que menos anda. */
136
+ function alinhados(dA, dB, n = PONTOS_PADRAO) {
137
+ const chave = 'al|' + n + '|' + dA + '|' + dB;
138
+ if (_memo.has(chave)) return _memo.get(chave);
139
+ const a = pontos(dA, n);
140
+ let b = pontos(dB, n);
141
+ if (Math.sign(area(a)) !== Math.sign(area(b))) b = [...b].reverse();
142
+ let melhor = 0, menor = Infinity;
143
+ for (let s = 0; s < n; s++) {
144
+ let soma = 0;
145
+ for (let i = 0; i < n && soma < menor; i++) {
146
+ const p = a[i], q = b[(i + s) % n];
147
+ soma += (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2;
148
+ }
149
+ if (soma < menor) { menor = soma; melhor = s; }
150
+ }
151
+ const r = { a, b: b.map((_, i) => b[(i + melhor) % n]) };
152
+ _memo.set(chave, r);
153
+ return r;
154
+ }
155
+
156
+ const f2 = (v) => String(Math.round(v * 100) / 100);
157
+ function caminhoDe(pts) {
158
+ return 'M ' + pts.map(([x, y]) => f2(x) + ' ' + f2(y)).join(' L ') + ' Z';
159
+ }
160
+
161
+ /** O caminho no instante `k` (0 = dA, 1 = dB). */
162
+ function morfar(dA, dB, k, n = PONTOS_PADRAO) {
163
+ const { a, b } = alinhados(dA, dB, n);
164
+ const t = Math.max(0, Math.min(1, k));
165
+ return caminhoDe(a.map((p, i) => [p[0] + (b[i][0] - p[0]) * t, p[1] + (b[i][1] - p[1]) * t]));
166
+ }
167
+
168
+ const ehCor = (v) => typeof v === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v.trim());
169
+ function rgb(h) {
170
+ let s = h.trim().slice(1);
171
+ if (s.length === 3) s = s.split('').map((c) => c + c).join('');
172
+ return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
173
+ }
174
+ function misturarCor(a, b, k) {
175
+ const x = rgb(a), y = rgb(b);
176
+ // A curva elástica passa de 1: a cor não pode passar de 255.
177
+ return '#' + x.map((v, i) => Math.max(0, Math.min(255, Math.round(v + (y[i] - v) * k))).toString(16).padStart(2, '0')).join('');
178
+ }
179
+ // ── fim do embutido ──
180
+
181
+ /** Este mesmo código como ES module, para o src/morfar.js do projeto Remotion. */
182
+ function fonteEsm() {
183
+ const src = require('fs').readFileSync(__filename, 'utf-8');
184
+ const ini = src.indexOf('// ── embutido');
185
+ const fim = src.indexOf('// ── fim do embutido');
186
+ return '/* Gerado de lib/estudio/morfar.js do PrimoCode — não edite aqui. */\n'
187
+ + src.slice(ini, fim)
188
+ + 'export { pontos, alinhados, caminhoDe, morfar, ehCor, misturarCor };\n';
189
+ }
190
+
191
+ module.exports = { pontos, alinhados, caminhoDe, morfar, ehCor, misturarCor, fonteEsm, PONTOS_PADRAO };