primocode 10.11.1 → 10.13.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 +15 -2
- package/lib/catalogo.js +7 -2
- package/lib/estudio/assinaturas/gerativo.json +1 -1
- package/lib/estudio/camadas.js +386 -0
- package/lib/estudio/diretor.js +2 -1
- package/lib/estudio/efeitos-fabrica/camadas-empilhadas.jsx +64 -0
- package/lib/estudio/efeitos-fabrica/terminal-digitando.jsx +58 -0
- package/lib/estudio/efeitos.js +2 -0
- package/lib/estudio/gerativo.js +44 -15
- package/lib/estudio/index.js +110 -2
- package/lib/estudio/kit/gerativo.jsx +15 -0
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/rosto.jsx +108 -0
- package/lib/estudio/kit.js +4 -1
- package/lib/estudio/kokoro.js +3 -1
- package/lib/estudio/remotion.js +6 -4
- package/lib/estudio/revisor.js +61 -0
- package/lib/estudio/voicestudio.js +81 -0
- package/lib/video/auto.js +18 -3
- package/package.json +2 -2
- package/skills/editar-meu-video/SKILL.md +6 -0
- package/skills/motion-de-primeira/SKILL.md +33 -51
package/lib/estudio/gerativo.js
CHANGED
|
@@ -510,7 +510,9 @@ function tituloDaFala(fala) {
|
|
|
510
510
|
|
|
511
511
|
function conteudoDe(p, marca = {}) {
|
|
512
512
|
const titulo = p.titulo || p.texto || tituloDaFala(p.fala);
|
|
513
|
-
|
|
513
|
+
// O item que repete o título ("Sem troco" / "sem troco", 15/10) sai: o quadro dizia a mesma coisa duas vezes.
|
|
514
|
+
const igual = (a, b) => tokens(a).join(' ') === tokens(b).join(' ');
|
|
515
|
+
const itens = itensDe(p).filter((it) => !igual(it, titulo));
|
|
514
516
|
if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
|
|
515
517
|
if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
|
|
516
518
|
if (p.tipo === 'contraste' && (p.nao || p.sim)) return { tipo: 'contraste', nao: p.nao || '', sim: p.sim || titulo };
|
|
@@ -528,7 +530,7 @@ const textoDoConteudo = (c) => [c.titulo, c.sub, c.valor, c.rotulo, c.nao, c.sim
|
|
|
528
530
|
const COMPOSICOES = ['gigante', 'pilha', 'divisao', 'contador', 'cascata', 'parede', 'citacao', 'marca', 'palavras', 'produto'];
|
|
529
531
|
const COMPORTAMENTOS = ['carimbo', 'mascara', 'onda', 'decodifica', 'peso', 'largura', 'tracking', 'desfoque', 'flap', 'pontos', 'contorno', 'monta'];
|
|
530
532
|
const SAIDAS = ['pontos', 'evapora', 'atravessa', 'desliza', 'comprime', 'explode', 'chicote', 'inunda', 'corte'];
|
|
531
|
-
const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta'];
|
|
533
|
+
const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta', 'visto'];
|
|
532
534
|
|
|
533
535
|
function pesosDe(c, { curto, palavras, primeira, ultima, tem3d, pergunta, exclama, numeroNoTitulo }) {
|
|
534
536
|
switch (c.tipo) {
|
|
@@ -1055,13 +1057,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
|
|
|
1055
1057
|
const M = Math.round(W * (deitado ? 0.085 : 0.095));
|
|
1056
1058
|
const larguraUtil = W - 2 * M;
|
|
1057
1059
|
const base = Math.min(W, H) / 1080;
|
|
1058
|
-
|
|
1060
|
+
/* (15/10, as referências) O rótulo de 24–30 px (1,6% da altura no 9:16)
|
|
1061
|
+
não se lia no celular ("NO COADOR", "pra você, de graça"). O rótulo
|
|
1062
|
+
fica em ~2% da altura, e a segunda voz (o sub, a frase de apoio) em
|
|
1063
|
+
3–4%, como a anotação das referências. */
|
|
1064
|
+
const tamRotulo = Math.round(Math.max((deitado ? 24 : 30) * base, H * (deitado ? 0.026 : 0.02)));
|
|
1059
1065
|
const tem3d = !!roteiro.modelo3d;
|
|
1060
1066
|
const ws = temposDasPartes(partes, tempos);
|
|
1061
1067
|
|
|
1062
1068
|
// ── os limites de cada parte na linha do tempo ────────────────────────
|
|
1063
1069
|
const inicio = [];
|
|
1064
|
-
|
|
1070
|
+
// 0,9 s (eram 1,4): nas referências a cena dura 1 a 2 s; com fala rápida a parte segue a voz.
|
|
1071
|
+
partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 0.9, ws[i].de - 0.12))));
|
|
1065
1072
|
const ultimaFala = ws[ws.length - 1];
|
|
1066
1073
|
const duracao = r1(Math.max(inicio[inicio.length - 1] + 2.0, (ultimaFala.ate || 0) + 1.15));
|
|
1067
1074
|
const fimParte = partes.map((_, i) => (i < partes.length - 1 ? inicio[i + 1] : duracao));
|
|
@@ -1544,14 +1551,18 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
|
|
|
1544
1551
|
resto); no fim, cada gesto vira o seu par limpo. */
|
|
1545
1552
|
/* O substituto é um limpo que não aparece nas 3 batidas de cada lado: a
|
|
1546
1553
|
passagem e o gesto da tese não voltam antes de 3 batidas. */
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1554
|
+
/* (15/10, as referências) Os pontos, o evapora e o comprime também
|
|
1555
|
+
espalhavam as letras ("dad|o", "· p o n d e", parecia glitch). Nas
|
|
1556
|
+
referências a frase sai inteira: desliza, atravessa ou corta seco. */
|
|
1557
|
+
const TORTOS = ['decodifica', 'flap', 'onda', 'monta', 'pontos'];
|
|
1558
|
+
const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'contorno', 'carimbo'];
|
|
1559
|
+
const SAIDAS_LIMPAS = ['desliza', 'atravessa', 'corte'];
|
|
1560
|
+
const SAIDAS_SUJAS = ['explode', 'chicote', 'pontos', 'evapora', 'comprime'];
|
|
1550
1561
|
const vizinhas = (i) => batidas.filter((_, j) => j !== i && Math.abs(j - i) <= 3);
|
|
1551
1562
|
const principal = (b) => (b.linhas || []).find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
|
|
1552
1563
|
const comGesto = batidas.filter(principal);
|
|
1553
1564
|
batidas.forEach((b, i) => {
|
|
1554
|
-
if (
|
|
1565
|
+
if (SAIDAS_SUJAS.includes(b.saida)) {
|
|
1555
1566
|
const perto = vizinhas(i).map((x) => x.saida);
|
|
1556
1567
|
b.saida = SAIDAS_LIMPAS.find((x) => !perto.includes(x)) || 'corte';
|
|
1557
1568
|
if (batidas[i + 1]) batidas[i + 1].entrada = b.saida;
|
|
@@ -1626,6 +1637,11 @@ function molduraDe(roteiro, E, { W, H, deitado, marca, secoes }) {
|
|
|
1626
1637
|
const pesoDe = (fonte) => (VARIAVEIS[fonte] && VARIAVEIS[fonte].eixos.wght) || [400, 400];
|
|
1627
1638
|
|
|
1628
1639
|
// ── as composições: de conteúdo + contexto, uma batida ─────────────────
|
|
1640
|
+
/** A segunda voz (sub, a frase de apoio): 3–4% da altura, como a anotação das referências. */
|
|
1641
|
+
function tamSegunda({ H, deitado, tamRotulo }) {
|
|
1642
|
+
return Math.round(Math.max(tamRotulo, H * (deitado ? 0.042 : 0.03)));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1629
1645
|
function compor(comp, br, ctx) {
|
|
1630
1646
|
const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
|
|
1631
1647
|
const c = br.conteudo;
|
|
@@ -1739,6 +1755,8 @@ function compor(comp, br, ctx) {
|
|
|
1739
1755
|
else {
|
|
1740
1756
|
const lt = comTese || teses[teses.length - 1];
|
|
1741
1757
|
b.acento.push({ forma, alvo: { linha: lt.id, palavra: comTese ? lt.palavras.findIndex((p) => p.papel === 'tese') : null } });
|
|
1758
|
+
// O círculo de caneta na tese ganha o ✓ (15/10, as referências), sem gastar sorteio.
|
|
1759
|
+
if (forma === 'anel' && comTese) b.acento.push({ forma: 'visto', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } });
|
|
1742
1760
|
}
|
|
1743
1761
|
}
|
|
1744
1762
|
// O ThreeCanvas só aceita pixel inteiro; ele ocupa a altura toda, para o chão não cortar.
|
|
@@ -1759,7 +1777,13 @@ function compor(comp, br, ctx) {
|
|
|
1759
1777
|
}
|
|
1760
1778
|
for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
|
|
1761
1779
|
const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
|
|
1762
|
-
if (lt) {
|
|
1780
|
+
if (lt) {
|
|
1781
|
+
tese(lt);
|
|
1782
|
+
const alvo = { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') };
|
|
1783
|
+
const forma = s.chance(0.6) ? 'sublinha' : 'anel';
|
|
1784
|
+
b.acento.push({ forma, alvo });
|
|
1785
|
+
if (forma === 'anel') b.acento.push({ forma: 'visto', alvo: { ...alvo } });
|
|
1786
|
+
}
|
|
1763
1787
|
else pontoFinal(b.linhas[b.linhas.length - 1]);
|
|
1764
1788
|
break;
|
|
1765
1789
|
}
|
|
@@ -1774,7 +1798,7 @@ function compor(comp, br, ctx) {
|
|
|
1774
1798
|
const l = linha(c.valor, 'numero', { fonte, tamanho: tam, eixos });
|
|
1775
1799
|
const tRot = Math.max(30 * base, Math.min(tam * 0.22, 64 * base));
|
|
1776
1800
|
if (c.rotulo) linha(c.rotulo, 'apoio', { tamanho: tRot, eixos: { wght: 600 }, fonte: fontes.display });
|
|
1777
|
-
if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho:
|
|
1801
|
+
if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.02, eixos: { wght: 400 } });
|
|
1778
1802
|
b.contador = { linha: l.id, ...n, dur: 0.9 };
|
|
1779
1803
|
b.acento.push({ forma: c.valor.length <= 4 ? 'anel' : 'sublinha', alvo: { linha: l.id } });
|
|
1780
1804
|
break;
|
|
@@ -1844,7 +1868,7 @@ function compor(comp, br, ctx) {
|
|
|
1844
1868
|
const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
|
|
1845
1869
|
b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
|
|
1846
1870
|
linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, pesoDe(fonte)[1]) }, comportamento: 'contorno' }).marca = true;
|
|
1847
|
-
if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho:
|
|
1871
|
+
if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamSegunda(ctx), tracking: 0.06, eixos: { wght: 400 } });
|
|
1848
1872
|
b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
|
|
1849
1873
|
break;
|
|
1850
1874
|
}
|
|
@@ -1952,9 +1976,10 @@ function tempoDaBatida(b, palavras, ctx) {
|
|
|
1952
1976
|
l.t = r3(Math.max(i === 0 ? b.de + 0.1 : t + REGRAS.stagger[0] * Q, falado != null ? falado : (i === 0 ? b.de + 0.12 : t + passo)));
|
|
1953
1977
|
t = l.t;
|
|
1954
1978
|
});
|
|
1955
|
-
/* Quadro morto não: a primeira coisa da batida entra em até 0,
|
|
1956
|
-
que a voz ainda não a tenha dito (o resto segue a voz).
|
|
1957
|
-
|
|
1979
|
+
/* Quadro morto não: a primeira coisa da batida entra em até 0,12 s, mesmo
|
|
1980
|
+
que a voz ainda não a tenha dito (o resto segue a voz). Eram 0,35 s, e
|
|
1981
|
+
com a saída da anterior o teste de 15/10 tinha 23% de quadro vazio. */
|
|
1982
|
+
if (ordem[0] && ordem[0].t > b.de + 0.12) ordem[0].t = r3(b.de + 0.12);
|
|
1958
1983
|
for (let i = 1; i < ordem.length; i++) if (ordem[i].t < ordem[i - 1].t + REGRAS.stagger[0] * Q) ordem[i].t = r3(ordem[i - 1].t + REGRAS.stagger[0] * Q);
|
|
1959
1984
|
// Nada entra tarde demais para ser lido: de trás para frente, com o stagger mínimo.
|
|
1960
1985
|
for (let i = ordem.length - 1; i >= 0; i--) {
|
|
@@ -1985,7 +2010,11 @@ function tempoDaBatida(b, palavras, ctx) {
|
|
|
1985
2010
|
let tw = l.t;
|
|
1986
2011
|
l.palavras.forEach((p, j) => {
|
|
1987
2012
|
const alvo = qs[j] != null ? qs[j] : (j === 0 ? l.t : tw + 2 * Q);
|
|
1988
|
-
|
|
2013
|
+
/* Quadro morto não, também na palavra (15/10): a voz pode dizer
|
|
2014
|
+
outra coisa antes ("Aponta a câmera, paga, e o pão…"); a primeira
|
|
2015
|
+
palavra da batida entra com a linha, as outras seguem a voz. */
|
|
2016
|
+
const abre = j === 0 && l === ordem[0];
|
|
2017
|
+
p.t = r3(abre ? l.t : Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
|
|
1989
2018
|
tw = p.t;
|
|
1990
2019
|
});
|
|
1991
2020
|
}
|
package/lib/estudio/index.js
CHANGED
|
@@ -65,6 +65,7 @@ const gerativo = require('./gerativo.js');
|
|
|
65
65
|
const fluido = require('./fluido.js');
|
|
66
66
|
const vox = require('./vox.js');
|
|
67
67
|
const kokoro = require('./kokoro.js');
|
|
68
|
+
const voicestudio = require('./voicestudio.js');
|
|
68
69
|
const lancamento = require('./lancamento.js');
|
|
69
70
|
const glifos = require('./glifos.js');
|
|
70
71
|
const personagem = require('./personagem.js');
|
|
@@ -77,6 +78,7 @@ const colecao = require('./colecao.js');
|
|
|
77
78
|
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
78
79
|
const sonsLeves = require('./sons-leves.js');
|
|
79
80
|
const semNarracao = require('./sem-narracao.js');
|
|
81
|
+
const camadas = require('./camadas.js');
|
|
80
82
|
const ende = require('./ende.js');
|
|
81
83
|
const producao = require('./producao.js');
|
|
82
84
|
|
|
@@ -862,6 +864,80 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
862
864
|
};
|
|
863
865
|
}
|
|
864
866
|
|
|
867
|
+
/* O VÍDEO EM CAMADAS (camadas.js, 14/10: "quero uma edição nesse nível"):
|
|
868
|
+
a pessoa só gravou a fala; a IA passa as batidas (forma do rosto, cena
|
|
869
|
+
de motion, destaque) e o Estúdio monta o bolo por cima. Sem batidas,
|
|
870
|
+
devolve uma proposta pela transcrição — a IA aceita ou muda. */
|
|
871
|
+
if (acao === 'camadas') {
|
|
872
|
+
const m = await video.medir(alvo);
|
|
873
|
+
if (!m.ok) return m;
|
|
874
|
+
// A transcrição fica guardada: a proposta e a montagem não pagam duas vezes.
|
|
875
|
+
const guardada = path.join(p.pasta, 'midia', `transcricao-${path.basename(alvo).replace(/\.[^.]+$/, '')}.json`);
|
|
876
|
+
let palavras = Array.isArray(args.palavras) && args.palavras.length ? args.palavras : null;
|
|
877
|
+
if (palavras) { try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ } }
|
|
878
|
+
if (!palavras && fs.existsSync(guardada)) { try { palavras = JSON.parse(fs.readFileSync(guardada, 'utf8')); } catch { palavras = null; } }
|
|
879
|
+
if (!palavras) {
|
|
880
|
+
const tr = await transcrever(p, alvo, ctx);
|
|
881
|
+
if (!tr.ok) return tr;
|
|
882
|
+
palavras = (tr.palavras && tr.palavras.length) ? tr.palavras : repartirTrechos(tr.trechos || []);
|
|
883
|
+
try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ }
|
|
884
|
+
}
|
|
885
|
+
const batidas = Array.isArray(args.batidas) && args.batidas.length ? args.batidas : null;
|
|
886
|
+
if (!batidas) {
|
|
887
|
+
return {
|
|
888
|
+
ok: true, proposta: camadas.propor(palavras, m.segundos), duracao: m.segundos,
|
|
889
|
+
comoUsar: 'é uma SUGESTÃO pela transcrição. Decida você: mude as formas, junte ou separe batidas, '
|
|
890
|
+
+ 'escreva a cena de motion de cada ideia (componentes/x.jsx, recebe {el, t, fps}; el.dados traz largura/altura do espaço livre, '
|
|
891
|
+
+ 'palavras com t, destaque, cores e fontes; a LEGENDA da fala já está no quadro, então a cena NÃO escreve a fala: desenha a ideia) ou use um efeito de fábrica pelo nome (estudio_midia acao:"efeitos"), '
|
|
892
|
+
+ 'e chame de novo com batidas:[{de, ate, rosto, cena?, destaque?, canto?, dados?}].',
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
// A cena: arquivo da pasta (conferido antes do render) ou efeito de fábrica pelo nome.
|
|
896
|
+
const problemas = [];
|
|
897
|
+
const prontas = batidas.map((b, i) => {
|
|
898
|
+
if (!b || !b.cena) return b;
|
|
899
|
+
const cena = String(b.cena).trim();
|
|
900
|
+
if (/[\\/]|\.(jsx|tsx|js)$/i.test(cena)) {
|
|
901
|
+
const cheio = dentro(cena);
|
|
902
|
+
if (!cheio || !fs.existsSync(cheio)) { problemas.push(`batidas[${i}]: a cena "${cena}" não está na pasta do projeto.`); return { ...b, cena: null }; }
|
|
903
|
+
const conf = conferirCena(cheio);
|
|
904
|
+
if (!conf.ok) { problemas.push(`batidas[${i}]: ${cena} — ${conf.erro}`); return { ...b, cena: null }; }
|
|
905
|
+
return { ...b, cena };
|
|
906
|
+
}
|
|
907
|
+
const r = efeitos.reusar({ pasta: p.pasta, nome: cena });
|
|
908
|
+
if (!r.ok) { problemas.push(`batidas[${i}]: ${r.error}`); return { ...b, cena: null }; }
|
|
909
|
+
return { ...b, cena: r.src };
|
|
910
|
+
});
|
|
911
|
+
if (problemas.length) return { ok: false, error: 'as cenas têm problema — conserte e mande de novo.', problemas };
|
|
912
|
+
|
|
913
|
+
/* A voz no volume de vídeo de internet (-15 LUFS), como na edição
|
|
914
|
+
automática; o vídeo em si não é recodificado. */
|
|
915
|
+
let fonte = alvo;
|
|
916
|
+
if (args.normalizar !== false) {
|
|
917
|
+
const normal = path.join(p.pasta, 'midia', `voz-normal-${path.basename(alvo).replace(/\.[^.]+$/, '')}.mp4`);
|
|
918
|
+
if (!fs.existsSync(normal)) {
|
|
919
|
+
await video.rodar(video.ffmpeg(), ['-y', '-i', alvo, '-c:v', 'copy',
|
|
920
|
+
'-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
|
|
921
|
+
}
|
|
922
|
+
if (fs.existsSync(normal)) fonte = normal;
|
|
923
|
+
}
|
|
924
|
+
const deitado = m.largura > m.altura;
|
|
925
|
+
const montado = camadas.montar({
|
|
926
|
+
src: path.relative(p.pasta, fonte).replace(/\\/g, '/'), duracao: m.segundos, palavras, batidas: prontas,
|
|
927
|
+
largura: deitado ? 1920 : 1080, altura: deitado ? 1080 : 1920, fps: 30,
|
|
928
|
+
origem: { largura: m.largura, altura: m.altura }, rosto: args.rosto || {}, acento: args.acento,
|
|
929
|
+
sons: sonsLeves.preparar(p.pasta),
|
|
930
|
+
});
|
|
931
|
+
if (!montado.ok) return montado;
|
|
932
|
+
const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `vídeo em camadas (${montado.resumo.batidas} batidas)` });
|
|
933
|
+
if (!salvo.ok) return salvo;
|
|
934
|
+
projeto.anotar(p.id, 'edicao', `camadas: ${montado.resumo.batidas} batidas, ${montado.resumo.cenas} cenas de motion, ${montado.resumo.trocasDeForma} trocas de forma`);
|
|
935
|
+
return {
|
|
936
|
+
ok: true, ...montado.resumo, batidas: montado.batidas, duracao: salvo.duracao,
|
|
937
|
+
proximo: 'estudio_render com este id. Para mudar uma batida, chame camadas de novo com a lista inteira.',
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
865
941
|
if (acao === 'recortar-pessoa' || acao === 'pessoa') {
|
|
866
942
|
const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
|
|
867
943
|
const r = await rodarPessoa([
|
|
@@ -1102,6 +1178,35 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
|
|
|
1102
1178
|
}
|
|
1103
1179
|
}
|
|
1104
1180
|
|
|
1181
|
+
/* A VOZ DO VOICESTUDIO (15/10): o app local fala o roteiro inteiro, e o resto
|
|
1182
|
+
é o caminho do Gemini — volume de Reels, a transcrição para achar as partes.
|
|
1183
|
+
Fechado ou falhando, devolve ok:false e a cadeia segue para o Gemini. */
|
|
1184
|
+
async function narrarComVoiceStudio(lista, mp3, { voz } = {}) {
|
|
1185
|
+
if (process.env.PRIMOCODE_SEM_VOICESTUDIO) return { ok: false, indisponivel: true, error: 'voicestudio desligado' };
|
|
1186
|
+
try {
|
|
1187
|
+
const texto = lista.map((x) => x.texto).join('\n\n');
|
|
1188
|
+
// A voz do Gemini ("Kore", "Charon") não existe lá: só um perfil do próprio app.
|
|
1189
|
+
const perfil = /^vs:/i.test(String(voz || '')) ? String(voz).slice(3) : undefined;
|
|
1190
|
+
const r = await voicestudio.falar(texto, { voz: perfil });
|
|
1191
|
+
if (!r.ok) return r;
|
|
1192
|
+
const bruto = mp3.replace(/\.mp3$/, '-vs.mp3');
|
|
1193
|
+
fs.writeFileSync(bruto, r.audio);
|
|
1194
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', bruto, '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
|
|
1195
|
+
try { fs.unlinkSync(bruto); } catch { /* fica */ }
|
|
1196
|
+
if (!c.ok) return { ok: false, error: 'voicestudio: não consegui converter a voz' };
|
|
1197
|
+
const med = await video.medir(mp3);
|
|
1198
|
+
const chars = lista.reduce((n, x) => n + x.texto.length, 0);
|
|
1199
|
+
if (med.segundos < chars / 22) return { ok: false, error: `voicestudio: a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
|
|
1200
|
+
const cfg = require('../config').loadConfig();
|
|
1201
|
+
const tr = await require('../api').requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
|
|
1202
|
+
const palavras = (tr && tr.palavras) || [];
|
|
1203
|
+
if (!palavras.length) return { ok: false, error: 'voicestudio: sem a transcrição da voz para achar as partes' };
|
|
1204
|
+
return { ok: true, voz: `voicestudio ${perfil || 'default'}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
return { ok: false, error: 'voicestudio: ' + e.message };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1105
1210
|
/* Onde estão os rostos em cada foto (studio/rostos.py, pelo uv). Guardado no
|
|
1106
1211
|
projeto: a mesma foto não é analisada duas vezes. */
|
|
1107
1212
|
async function acharRostos(p, srcs) {
|
|
@@ -1195,7 +1300,9 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
|
1195
1300
|
if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
|
|
1196
1301
|
// A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
|
|
1197
1302
|
if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
|
|
1198
|
-
|
|
1303
|
+
// O VoiceStudio local primeiro (15/10); fechado ou falhando, o Gemini.
|
|
1304
|
+
let v = await narrarComVoiceStudio(lista, mp3, { voz });
|
|
1305
|
+
if (!v.ok) v = await narrarComGemini(lista, mp3, { voz, direcao });
|
|
1199
1306
|
if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
|
|
1200
1307
|
return v;
|
|
1201
1308
|
}
|
|
@@ -1214,6 +1321,7 @@ async function produzirNecessidade(p, roteiro, pedido, provedores) {
|
|
|
1214
1321
|
direção dele), o Kokoro local e o Edge. A falha de uma passa para a próxima. */
|
|
1215
1322
|
function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini, escolha } = {}) {
|
|
1216
1323
|
const todos = {
|
|
1324
|
+
voicestudio: () => narrarComVoiceStudio(lista, mp3, { voz }),
|
|
1217
1325
|
gemini: () => gemini(),
|
|
1218
1326
|
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
1219
1327
|
edge: () => {
|
|
@@ -1232,7 +1340,7 @@ function provedoresDeVoz(lista, mp3, arq, voz, vozEdge, { soEdge = false, gemini
|
|
|
1232
1340
|
/* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
|
|
1233
1341
|
depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
|
|
1234
1342
|
function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
|
|
1235
|
-
const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
|
|
1343
|
+
const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && n !== 'voicestudio' && !(semKokoro && n === 'kokoro'));
|
|
1236
1344
|
return kokoro.corrente(passos, {
|
|
1237
1345
|
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
1238
1346
|
edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', kokoro.vozNoEdge(voz, vozEdge)], { timeout: 10 * 60000 }),
|
|
@@ -295,6 +295,21 @@ function geometria(a, b, L, P) {
|
|
|
295
295
|
for (let i = 0; i < N; i++) { const q = -2.3 + (i / (N - 1)) * Math.PI * 2 * 1.1; const e = 1 + (i / N) * 0.07; pts.push([ccx + Math.cos(q) * rx * e, ccy + Math.sin(q) * ry * e]); }
|
|
296
296
|
return { pts, larg: Math.max(4 * base, cx.s * 0.03), alfa: 1, frente: true, cx: ccx, cy: ccy };
|
|
297
297
|
}
|
|
298
|
+
if (f === 'visto') {
|
|
299
|
+
/* O ✓ de caneta (15/10, as referências): depois do círculo, ao lado da
|
|
300
|
+
palavra, em dois traços; se não cabe à direita, encosta na margem. */
|
|
301
|
+
const pw = alvoPalavra();
|
|
302
|
+
const s = cx.s, cy0 = cx.y + cx.h * 0.5;
|
|
303
|
+
const A = [pw.x + pw.w + s * 0.16, cy0], B = [pw.x + pw.w + s * 0.27, cy0 + s * 0.2], C = [pw.x + pw.w + s * 0.52, cy0 - s * 0.3];
|
|
304
|
+
const dx = Math.min(0, P.largura - 24 * base - C[0]);
|
|
305
|
+
const q = 0.3;
|
|
306
|
+
for (let i = 0; i < N; i++) {
|
|
307
|
+
const k = i / (N - 1);
|
|
308
|
+
const [p0, p1, u] = k < q ? [A, B, k / q] : [B, C, (k - q) / (1 - q)];
|
|
309
|
+
pts.push([lerp(p0[0], p1[0], u) + dx, lerp(p0[1], p1[1], u)]);
|
|
310
|
+
}
|
|
311
|
+
return { pts, larg: Math.max(5 * base, s * 0.045), alfa: 1, frente: true, tampa: 'round', cx: B[0] + dx, cy: B[1] };
|
|
312
|
+
}
|
|
298
313
|
if (f === 'barra') {
|
|
299
314
|
// O vão da barra é o respiro do vídeo (margem.js): colada, ela riscava a primeira letra.
|
|
300
315
|
// A deriva da vida abre as letras até 3% da linha para fora: a barra recua junto.
|
|
@@ -12,6 +12,7 @@ import { FLUIDO } from './fluido';
|
|
|
12
12
|
import { PERSONAGEM } from './personagem';
|
|
13
13
|
import { VOXEL } from './voxel';
|
|
14
14
|
import { ANIMACAO } from './animacao';
|
|
15
|
+
import { ROSTO } from './rosto';
|
|
15
16
|
|
|
16
|
-
export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
|
|
17
|
+
export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, ...ROSTO, papel: Papel };
|
|
17
18
|
export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* kit/rosto.jsx — o rosto de quem fala, que muda de forma (camadas.js).
|
|
2
|
+
*
|
|
3
|
+
* "o vídeo da pessoa é contínuo, mas o ROSTO muda de forma: tela cheia,
|
|
4
|
+
* cartão com borda que brilha, bolinha no canto, some" (Joel, 14/10)
|
|
5
|
+
*
|
|
6
|
+
* Duas peças:
|
|
7
|
+
* - palco: o fundo escuro com brilho que respira e grão (o que aparece
|
|
8
|
+
* quando o rosto não ocupa a tela);
|
|
9
|
+
* - rosto: a gravação dentro de uma caixa que vai de `antes` para `agora`
|
|
10
|
+
* com mola (kit/natural.js): recua um tico (antecipação), vai, passa do
|
|
11
|
+
* ponto e assenta. A gravação começa em `deOrigem` e NÃO reinicia: cada
|
|
12
|
+
* cena pega do ponto em que a anterior parou, e o áudio dela é a fala do
|
|
13
|
+
* vídeo. Quando "some", o vídeo continua tocando, só invisível.
|
|
14
|
+
* A geometria (onde fica cada caixa) vem pronta do camadas.js; aqui só se
|
|
15
|
+
* interpola. Nada de sorteio: o mesmo quadro sai igual sempre. */
|
|
16
|
+
import React from 'react';
|
|
17
|
+
import { AbsoluteFill, Video, staticFile, useVideoConfig } from 'remotion';
|
|
18
|
+
import { mola, ruido } from './natural';
|
|
19
|
+
|
|
20
|
+
const lerp = (a, b, k) => a + (b - a) * k;
|
|
21
|
+
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
|
|
22
|
+
const CAMPOS = ['x', 'y', 'w', 'h', 'raio', 'anel', 'zoom', 'fx', 'fy'];
|
|
23
|
+
|
|
24
|
+
/** A caixa no instante t (s) da cena. */
|
|
25
|
+
export function caixaNoTempo(el, t) {
|
|
26
|
+
const B = el.agora || el.antes || {};
|
|
27
|
+
const A = el.antes || B;
|
|
28
|
+
if (!el.mudou) return { ...B };
|
|
29
|
+
const s = t - 0.05;
|
|
30
|
+
// A mola com peso: um pouco de sobra, e assenta em ~0,6 s.
|
|
31
|
+
const k = mola(s, { freq: 1.55, amort: 0.62 });
|
|
32
|
+
const kc = clamp(k, 0, 1);
|
|
33
|
+
// A antecipação: antes de ir, a caixa encolhe um tico (o "respiro" do gesto).
|
|
34
|
+
const prep = s > 0 && s < 0.14 ? Math.sin(Math.PI * (s / 0.14)) * 0.035 : 0;
|
|
35
|
+
const o = {};
|
|
36
|
+
for (const c of CAMPOS) o[c] = lerp(A[c] ?? B[c] ?? 0, B[c] ?? A[c] ?? 0, k);
|
|
37
|
+
o.raio = Math.max(0, o.raio);
|
|
38
|
+
o.anel = Math.max(0, o.anel);
|
|
39
|
+
o.w = Math.max(1, o.w); o.h = Math.max(1, o.h);
|
|
40
|
+
o.opacidade = lerp(A.opacidade ?? 1, B.opacidade ?? 1, kc);
|
|
41
|
+
o.escala = Math.max(0, lerp(A.escala ?? 1, B.escala ?? 1, k)) * (1 - prep);
|
|
42
|
+
return o;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Rosto({ el, t, fps }) {
|
|
46
|
+
const c = caixaNoTempo(el, t);
|
|
47
|
+
const ac = el.acento || '#8B7CF6';
|
|
48
|
+
const origem = el.origem || {};
|
|
49
|
+
const sw = origem.largura || 1080, sh = origem.altura || 1920;
|
|
50
|
+
// A gravação cobre a caixa (cover) com o zoom, e o rosto (fx, fy) vai para o centro, acima do meio (o queixo longe da legenda).
|
|
51
|
+
const s = Math.max(c.w / sw, c.h / sh) * Math.max(1, c.zoom || 1);
|
|
52
|
+
const vw = sw * s, vh = sh * s;
|
|
53
|
+
const left = clamp(c.w * 0.5 - (c.fx ?? 0.5) * vw, c.w - vw, 0);
|
|
54
|
+
const top = clamp(c.h * 0.38 - (c.fy ?? 0.4) * vh, c.h - vh, 0);
|
|
55
|
+
// A bolinha e o cartão flutuam de leve: parado de todo parece colado.
|
|
56
|
+
const flutua = el.forma === 'bolinha' || el.forma === 'cartao' ? ruido(t * 0.35, 11) * 4 : 0;
|
|
57
|
+
const anel = c.anel;
|
|
58
|
+
const brilho = anel > 0
|
|
59
|
+
? `0 0 ${Math.round(18 + anel * 5)}px ${ac}88, 0 0 ${Math.round(60 + anel * 10)}px ${ac}33, 0 26px 70px rgba(0,0,0,.6)`
|
|
60
|
+
: 'none';
|
|
61
|
+
return (
|
|
62
|
+
<div style={{
|
|
63
|
+
position: 'absolute', left: c.x, top: c.y + flutua, width: c.w, height: c.h,
|
|
64
|
+
transform: `scale(${c.escala})`, transformOrigin: '50% 50%', opacity: c.opacidade,
|
|
65
|
+
borderRadius: c.raio, padding: anel,
|
|
66
|
+
// A borda que brilha: um degradê do acento ao branco, por trás do vídeo.
|
|
67
|
+
background: anel > 0 ? `linear-gradient(150deg, #ffffffcc, ${ac} 38%, ${ac}aa 70%, #ffffff88)` : 'transparent',
|
|
68
|
+
boxShadow: brilho, boxSizing: 'border-box',
|
|
69
|
+
}}>
|
|
70
|
+
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', borderRadius: Math.max(0, c.raio - anel), background: '#000' }}>
|
|
71
|
+
<Video
|
|
72
|
+
src={staticFile(el.src)}
|
|
73
|
+
startFrom={el.deOrigem ? Math.round(el.deOrigem * fps) : undefined}
|
|
74
|
+
volume={el.volume ?? 1}
|
|
75
|
+
style={{ position: 'absolute', left, top, width: vw, height: vh, maxWidth: 'none' }}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** O palco: preto azulado, dois brilhos que derivam devagar, vinheta e grão. */
|
|
83
|
+
export function Palco({ el, t }) {
|
|
84
|
+
const { width: W, height: H } = useVideoConfig();
|
|
85
|
+
const ac = el.acento || '#8B7CF6';
|
|
86
|
+
const dx = ruido(t * 0.12, 3 + (el.semente || 0)) * 7;
|
|
87
|
+
const dy = ruido(t * 0.1, 7 + (el.semente || 0)) * 5;
|
|
88
|
+
const quadro = Math.floor(t * 24);
|
|
89
|
+
const id = 'grao-' + (el.semente || 0);
|
|
90
|
+
return (
|
|
91
|
+
<AbsoluteFill style={{ background: '#07060c', overflow: 'hidden' }}>
|
|
92
|
+
<div style={{ position: 'absolute', inset: 0, background:
|
|
93
|
+
`radial-gradient(55% 38% at ${50 + dx}% ${58 + dy}%, ${ac}40, transparent 72%),`
|
|
94
|
+
+ `radial-gradient(45% 30% at ${16 - dx}% ${16 - dy}%, #4f46e526, transparent 70%),`
|
|
95
|
+
+ `radial-gradient(70% 45% at ${80 + dy}% ${96}%, #1d4ed838, transparent 72%)` }} />
|
|
96
|
+
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(120% 80% at 50% 45%, transparent 55%, rgba(0,0,0,.55) 100%)' }} />
|
|
97
|
+
<svg width={W} height={H} style={{ position: 'absolute', inset: 0, opacity: el.grao ?? 0.13, mixBlendMode: 'screen' }}>
|
|
98
|
+
<filter id={id}>
|
|
99
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={quadro % 61} stitchTiles="stitch" />
|
|
100
|
+
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1.1 -0.45" />
|
|
101
|
+
</filter>
|
|
102
|
+
<rect width="100%" height="100%" filter={`url(#${id})`} />
|
|
103
|
+
</svg>
|
|
104
|
+
</AbsoluteFill>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const ROSTO = { rosto: Rosto, palco: Palco };
|
package/lib/estudio/kit.js
CHANGED
|
@@ -65,9 +65,12 @@ const PECAS = {
|
|
|
65
65
|
voxel: { precisa: ['partitura'] },
|
|
66
66
|
// A animação pronta da biblioteca (kit/animacao.jsx, colecao.js): o Lottie tocado quadro a quadro.
|
|
67
67
|
animacao: { precisa: ['src'] },
|
|
68
|
+
// O vídeo em camadas (kit/rosto.jsx, camadas.js): a gravação que muda de forma, e o palco escuro atrás dela.
|
|
69
|
+
rosto: { precisa: ['src'] },
|
|
70
|
+
palco: {},
|
|
68
71
|
};
|
|
69
72
|
|
|
70
|
-
const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'natural.js', 'index.jsx'];
|
|
73
|
+
const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'rosto.jsx', 'natural.js', 'index.jsx'];
|
|
71
74
|
|
|
72
75
|
/** Os arquivos do kit, para o projeto Remotion: [{nome: 'kit/base.jsx', conteudo}]. */
|
|
73
76
|
function arquivos() {
|
package/lib/estudio/kokoro.js
CHANGED
|
@@ -94,7 +94,9 @@ async function narrar(lista, mp3, { voz, uv, espeak = acharEspeak(), executar =
|
|
|
94
94
|
roteiro pula as outras. */
|
|
95
95
|
function ordem(voz, env = process.env, { soEdge = false } = {}) {
|
|
96
96
|
if (soEdge) return ['edge'];
|
|
97
|
-
|
|
97
|
+
if (querKokoro(voz, env)) return ['kokoro', 'edge'];
|
|
98
|
+
// O VoiceStudio local vem primeiro (15/10); fechado, a cadeia segue para o Gemini na hora.
|
|
99
|
+
return env.PRIMOCODE_SEM_VOICESTUDIO ? ['gemini', 'edge', 'kokoro'] : ['voicestudio', 'gemini', 'edge', 'kokoro'];
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/* A voz do Gemini no Edge: o narrar.py só conhece antonio, francisca e
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -108,9 +108,9 @@ import { KIT, CURVAS_KIT, Filtros } from './kit';
|
|
|
108
108
|
import { COMPONENTES } from './componentes';
|
|
109
109
|
|
|
110
110
|
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
111
|
-
* Cada curva é uma função de 0..1 em 0..1. "
|
|
112
|
-
* movimento linear
|
|
113
|
-
*
|
|
111
|
+
* Cada curva é uma função de 0..1 em 0..1. A "editorial" é o padrão (15/10):
|
|
112
|
+
* movimento linear denuncia máquina, e o entra-e-sai devagar da "suave" lia
|
|
113
|
+
* como fade; as referências atacam rápido e desaceleram forte. */
|
|
114
114
|
const CURVAS = {
|
|
115
115
|
linear: (t) => t,
|
|
116
116
|
suave: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
|
|
@@ -143,7 +143,9 @@ function valorEm(trilha, t, padrao) {
|
|
|
143
143
|
const a = trilha[i], b = trilha[i + 1];
|
|
144
144
|
if (t >= a.t && t <= b.t) {
|
|
145
145
|
const bruto = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
|
|
146
|
-
|
|
146
|
+
/* (15/10, as referências) Sem curva dita, a editorial: ataque rápido e
|
|
147
|
+
desaceleração forte. A "suave" (entra e sai devagar) lia como fade. */
|
|
148
|
+
const curva = CURVAS[b.e || a.e || 'editorial'] || CURVAS.editorial;
|
|
147
149
|
const k = curva(bruto);
|
|
148
150
|
/* Texto no lugar do número: é COR ("#ff6a00") ou CAMINHO (o "d" de um
|
|
149
151
|
SVG). A cor mistura; o caminho se deforma (morfar.js): a bolinha
|
package/lib/estudio/revisor.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* avisa a mesma sequência de elementos em 4+ cenas seguidas (cara de template)
|
|
17
17
|
* avisa imagem sem a pessoa pedir, ou imagem em mais da metade das cenas
|
|
18
18
|
* conserta efeito sonoro demais (mais de 1 a cada 1,5 s) → ficam os mais altos
|
|
19
|
+
* avisa texto pequeno demais para o celular (< 2% da altura no 9:16)
|
|
20
|
+
* avisa texto com contraste abaixo de 3:1 contra o fundo da cena
|
|
19
21
|
* Puro: o plano entra, o plano consertado e a lista saem.
|
|
20
22
|
*/
|
|
21
23
|
|
|
@@ -34,6 +36,62 @@ const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
|
|
|
34
36
|
// Imagem ou vídeo DE FORA: o vídeo da própria gravação (deOrigem) e o recorte da pessoa não contam.
|
|
35
37
|
const imagemDeFora = (el) => el && (el.tipo === 'imagem' || (el.tipo === 'video' && el.deOrigem == null && !/pessoa-/.test(el.src || '')));
|
|
36
38
|
|
|
39
|
+
/* O TEXTO QUE NÃO SE LÊ (15/10, as referências): rótulo de 1,6% da altura
|
|
40
|
+
("NO COADOR") e laranja sobre bege. Nas referências a palavra principal
|
|
41
|
+
ocupa 55–85% da largura e a segunda voz ~5% da altura, sempre em contraste
|
|
42
|
+
duro. Contraste é o da WCAG para texto grande (3:1). */
|
|
43
|
+
const hex6 = (c) => {
|
|
44
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(c || '').trim());
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
const h = m[1].length === 3 ? m[1].split('').map((x) => x + x).join('') : m[1];
|
|
47
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
48
|
+
};
|
|
49
|
+
const luminancia = (c) => {
|
|
50
|
+
const v = hex6(c);
|
|
51
|
+
if (!v) return null;
|
|
52
|
+
const [r, g, b] = v.map((x) => (x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)));
|
|
53
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
54
|
+
};
|
|
55
|
+
function contraste(a, b) {
|
|
56
|
+
const la = luminancia(a), lb = luminancia(b);
|
|
57
|
+
if (la == null || lb == null) return null;
|
|
58
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
59
|
+
}
|
|
60
|
+
const corDoFundo = (f) => (typeof f === 'string' ? f : f && (f.valor || f.cor)) || null;
|
|
61
|
+
|
|
62
|
+
function revisarLeitura(plano, avisos) {
|
|
63
|
+
const W = Number(plano.formato && plano.formato.largura) || 1920, H = Number(plano.formato && plano.formato.altura) || 1080;
|
|
64
|
+
const piso = H * (W > H ? 0.024 : 0.019);
|
|
65
|
+
const curto = (t) => String(t || '').slice(0, 24);
|
|
66
|
+
const pequenos = [], apagados = [];
|
|
67
|
+
(plano.cenas || []).forEach((c, i) => {
|
|
68
|
+
const fundo = corDoFundo(c.fundo) || plano.fundoGeral;
|
|
69
|
+
for (const e of c.elementos || []) {
|
|
70
|
+
if (!e) continue;
|
|
71
|
+
if (e.tipo === 'texto') {
|
|
72
|
+
const tam = Number(e.tamanho || (e.estilo && e.estilo.tamanho));
|
|
73
|
+
if (tam && tam < piso) pequenos.push(`cena ${i + 1} "${curto(e.texto)}" ${r2((tam / H) * 100)}%`);
|
|
74
|
+
const k = contraste(e.cor || (e.estilo && e.estilo.cor), fundo);
|
|
75
|
+
if (k != null && k < 3) apagados.push(`cena ${i + 1} "${curto(e.texto)}" ${r2(k)}:1`);
|
|
76
|
+
}
|
|
77
|
+
if (e.tipo === 'kit' && e.peca === 'gerativo' && e.partitura) {
|
|
78
|
+
const Hp = Number(e.partitura.altura) || H;
|
|
79
|
+
const pisoP = Hp * ((Number(e.partitura.largura) || W) > Hp ? 0.024 : 0.019);
|
|
80
|
+
for (const b of e.partitura.batidas || []) {
|
|
81
|
+
const cores = b.cores || {};
|
|
82
|
+
for (const l of b.linhas || []) {
|
|
83
|
+
if (l.tamanho && l.tamanho < pisoP) pequenos.push(`batida ${r2(b.de)}s "${curto(l.texto)}" ${r2((l.tamanho / Hp) * 100)}%`);
|
|
84
|
+
}
|
|
85
|
+
const k = contraste(cores.texto, cores.fundo);
|
|
86
|
+
if (k != null && k < 3) apagados.push(`batida ${r2(b.de)}s ${cores.texto} sobre ${cores.fundo} ${r2(k)}:1`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (pequenos.length) avisos.push(`texto pequeno demais para o celular (piso ~2% da altura): ${pequenos.slice(0, 3).join('; ')}${pequenos.length > 3 ? ` e mais ${pequenos.length - 3}` : ''}`);
|
|
92
|
+
if (apagados.length) avisos.push(`texto com contraste abaixo de 3:1 contra o fundo: ${apagados.slice(0, 3).join('; ')}${apagados.length > 3 ? ` e mais ${apagados.length - 3}` : ''}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
37
95
|
function revisar(plano, { semImagem = false } = {}) {
|
|
38
96
|
const consertos = [], avisos = [];
|
|
39
97
|
const cenas = (plano && plano.cenas) || [];
|
|
@@ -114,6 +172,9 @@ function revisar(plano, { semImagem = false } = {}) {
|
|
|
114
172
|
if (semImagem && comImagem) avisos.push(`${comImagem} cena(s) com imagem de fora, mas a pessoa não pediu imagem`);
|
|
115
173
|
else if (cenas.length >= 4 && comImagem / cenas.length > 0.5) avisos.push(`imagem em ${comImagem} de ${cenas.length} cenas — a imagem é um momento, não o vídeo inteiro`);
|
|
116
174
|
|
|
175
|
+
// 7. O texto que não se lê: pequeno demais ou sem contraste.
|
|
176
|
+
revisarLeitura(plano, avisos);
|
|
177
|
+
|
|
117
178
|
return { plano, consertos, avisos };
|
|
118
179
|
}
|
|
119
180
|
|