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 +2 -2
- package/lib/estudio/cinema.js +5 -3
- package/lib/estudio/edicao.js +32 -7
- package/lib/estudio/ende.js +109 -0
- package/lib/estudio/index.js +38 -9
- package/lib/estudio/morfar.js +191 -0
- package/lib/estudio/motion.js +336 -0
- package/lib/estudio/plano.js +47 -6
- package/lib/estudio/producao.js +106 -0
- package/lib/estudio/remotion.js +81 -10
- package/lib/estudio/sons-leves.js +62 -0
- package/package.json +2 -2
- package/skills/motion-de-primeira/SKILL.md +21 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.
|
|
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.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.10.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/estudio/cinema.js
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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);
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(/&/g, '&').replace(/�?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 };
|
package/lib/estudio/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
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 };
|