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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* motion.js — o roteiro vira UM plano contínuo, em 3D. Não slide.
|
|
3
|
+
*
|
|
4
|
+
* "Isso aí tá muito bom. Eu quero agora que os motion saiam desse estilo,
|
|
5
|
+
* as animações assim, motion assim mesmo. Isso sim é motion." — Joel,
|
|
6
|
+
* depois do exemplo da bolinha (28/09/2026)
|
|
7
|
+
*
|
|
8
|
+
* A composição (composicao.js) faz uma cena por parte: título, corta, lista,
|
|
9
|
+
* corta. Isso é slide, por mais que cada cena se mexa. Aqui existe um
|
|
10
|
+
* PROTAGONISTA — uma forma na cor da marca — que vive o vídeo inteiro e se
|
|
11
|
+
* deforma no que cada parte pede:
|
|
12
|
+
*
|
|
13
|
+
* abertura esfera que chega do fundo do espaço
|
|
14
|
+
* titulo pílula que carrega o título
|
|
15
|
+
* numero disco com o número contando
|
|
16
|
+
* lista pílula do título, e os itens saem dela em leque 3D
|
|
17
|
+
* passos idem, numerados
|
|
18
|
+
* contraste o "não" riscado cai, o "sim" sobe no lugar
|
|
19
|
+
* citacao cartão com a frase
|
|
20
|
+
* imagem moldura, e a foto aparece dentro dela
|
|
21
|
+
* cta o botão; o cursor entra girando e clica
|
|
22
|
+
*
|
|
23
|
+
* Uma cena só, 60 quadros por segundo. Cada parte entra antes de a anterior
|
|
24
|
+
* sair, a câmera orbita o mundo o tempo todo, partículas em três
|
|
25
|
+
* profundidades dão o espaço, e o protagonista tem espessura (uma cópia mais
|
|
26
|
+
* escura atrás). É a mesma gramática do exemplo docs/exemplos/motion-bolinha.
|
|
27
|
+
*
|
|
28
|
+
* Puro: recebe tempos, narração, trilha e sons já prontos; devolve o plano.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { caber, falaDe } = require('./composicao.js');
|
|
34
|
+
const { misturarCor, ehCor } = require('./morfar.js');
|
|
35
|
+
|
|
36
|
+
const k = (t, v, e) => (e ? { t, v, e } : { t, v });
|
|
37
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
38
|
+
const FPS = 60;
|
|
39
|
+
|
|
40
|
+
// ── o protagonista: caixa de 1400 px, formas em 0..100 ───────────────────
|
|
41
|
+
const CAIXA = 1400;
|
|
42
|
+
const U = CAIXA / 100; // px por unidade
|
|
43
|
+
const KAPPA = 0.5523;
|
|
44
|
+
/** Retângulo de cantos redondos em unidades (é também círculo e pílula). */
|
|
45
|
+
function forma(w, h, r = Math.min(w, h) / 2) {
|
|
46
|
+
const x0 = 50 - w / 2, y0 = 50 - h / 2, x1 = 50 + w / 2, y1 = 50 + h / 2;
|
|
47
|
+
const rr = Math.min(r, w / 2, h / 2), c = rr * KAPPA;
|
|
48
|
+
const f = (n) => r2(n);
|
|
49
|
+
return [
|
|
50
|
+
`M ${f(x0 + rr)} ${f(y0)}`, `L ${f(x1 - rr)} ${f(y0)}`,
|
|
51
|
+
`C ${f(x1 - rr + c)} ${f(y0)} ${f(x1)} ${f(y0 + rr - c)} ${f(x1)} ${f(y0 + rr)}`,
|
|
52
|
+
`L ${f(x1)} ${f(y1 - rr)}`,
|
|
53
|
+
`C ${f(x1)} ${f(y1 - rr + c)} ${f(x1 - rr + c)} ${f(y1)} ${f(x1 - rr)} ${f(y1)}`,
|
|
54
|
+
`L ${f(x0 + rr)} ${f(y1)}`,
|
|
55
|
+
`C ${f(x0 + rr - c)} ${f(y1)} ${f(x0)} ${f(y1 - rr + c)} ${f(x0)} ${f(y1 - rr)}`,
|
|
56
|
+
`L ${f(x0)} ${f(y0 + rr)}`,
|
|
57
|
+
`C ${f(x0)} ${f(y0 + rr - c)} ${f(x0 + rr - c)} ${f(y0)} ${f(x0 + rr)} ${f(y0)}`, 'Z',
|
|
58
|
+
].join(' ');
|
|
59
|
+
}
|
|
60
|
+
const px = (n) => n / U; // px → unidades da caixa
|
|
61
|
+
|
|
62
|
+
const numeroDe = (v) => {
|
|
63
|
+
const m = String(v == null ? '' : v).match(/^(\D*)([\d.,]+)(.*)$/);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const n = Number(m[2].replace(/\./g, '').replace(',', '.'));
|
|
66
|
+
return Number.isFinite(n) ? { antes: m[1], ate: n, depois: m[3], casas: /,\d/.test(m[2]) ? 1 : 0 } : null;
|
|
67
|
+
};
|
|
68
|
+
const itensDe = (p) => (p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} op
|
|
72
|
+
* @param {object} op.roteiro { marca, partes: [{tipo, titulo, sub, itens, valor, rotulo, sim, nao, link, autor, src}] }
|
|
73
|
+
* @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração); sem ela, 2,4 s por parte
|
|
74
|
+
* @param {string} op.narracao o mp3 da narração inteira
|
|
75
|
+
* @param {object} op.sons { sopro, bolha, clique, brilho, subida, toque } (sons-leves.js)
|
|
76
|
+
* @param {string} op.trilha a música
|
|
77
|
+
* @param {object} op.imagens { indiceDaParte: {src, tipo} }
|
|
78
|
+
*/
|
|
79
|
+
function montarMotion({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, imagens = {} }) {
|
|
80
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
81
|
+
if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
|
|
82
|
+
const W = largura, H = altura, deitado = W >= H;
|
|
83
|
+
const base = Math.min(W, H) / 1080;
|
|
84
|
+
|
|
85
|
+
// ── a marca ──────────────────────────────────────────────────────────
|
|
86
|
+
const marca = roteiro.marca || {};
|
|
87
|
+
const cores = marca.cores || {};
|
|
88
|
+
const DEST = ehCor(cores.destaque) ? cores.destaque : (marca.gradiente && ehCor(marca.gradiente[1]) ? marca.gradiente[1] : '#7c5cff');
|
|
89
|
+
const DEST2 = marca.gradiente && ehCor(marca.gradiente[0]) ? marca.gradiente[0] : misturarCor(DEST, '#ffffff', 0.35);
|
|
90
|
+
const FUNDO = ehCor(cores.fundo) ? cores.fundo : '#0a0a12';
|
|
91
|
+
const TINTA = ehCor(cores.tinta) ? cores.tinta : '#ffffff';
|
|
92
|
+
const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Plus Jakarta Sans', texto: (marca.fontes && marca.fontes.texto) || 'Plus Jakarta Sans' };
|
|
93
|
+
const LUZ = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#ffffff', 0.55), DEST, misturarCor(DEST, '#000000', 0.45)] };
|
|
94
|
+
const LADO = { tipo: 'gradiente', radial: true, centro: [0.32, 0.26], raio: 0.9, cores: [misturarCor(DEST, '#000000', 0.35), misturarCor(DEST, '#000000', 0.6)] };
|
|
95
|
+
|
|
96
|
+
// ── o tempo de cada parte (a narração manda) ────────────────────────
|
|
97
|
+
const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
|
|
98
|
+
const ini = partes.map((_, i) => (temVoz ? (i === 0 ? 0 : Math.max(0, tempos[i].de - 0.25)) : i * 2.4));
|
|
99
|
+
const DUR = r2(temVoz ? tempos[tempos.length - 1].ate + 1.3 : partes.length * 2.4 + 1.0);
|
|
100
|
+
const fim = partes.map((_, i) => (i < partes.length - 1 ? ini[i + 1] : DUR));
|
|
101
|
+
|
|
102
|
+
const elementos = [];
|
|
103
|
+
const efeitos = [];
|
|
104
|
+
const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
|
|
105
|
+
|
|
106
|
+
// Trilhas do protagonista, construídas parte a parte.
|
|
107
|
+
const trilhaForma = [], trilhaX = [], trilhaY = [], trilhaRot = [], trilhaEsc = [];
|
|
108
|
+
|
|
109
|
+
// Uma peça de conteúdo: entra do 3D (z, giro em X), sai recuando e desfocando.
|
|
110
|
+
const peca = (el, de, ate, { atraso = 0 } = {}) => {
|
|
111
|
+
const d0 = r2(de + atraso), vida = r2(Math.max(0.5, ate - d0));
|
|
112
|
+
const a = el.anima || {};
|
|
113
|
+
return {
|
|
114
|
+
...el, de: d0, ate: r2(d0 + vida),
|
|
115
|
+
anima: {
|
|
116
|
+
...a,
|
|
117
|
+
opacidade: [k(0, 0), k(0.3, 1, 'saida'), k(Math.max(0.35, vida - 0.3), 1), k(vida, 0, 'suave')],
|
|
118
|
+
// 70 px à frente do protagonista: quando ele gira, a lateral
|
|
119
|
+
// dele avança em z e, mais perto que isso, cobria o texto.
|
|
120
|
+
z: [k(0, 260), k(0.5, 70, 'saida'), k(Math.max(0.55, vida - 0.3), 70), k(vida, -260, 'suave')],
|
|
121
|
+
rotacaoX: a.rotacaoX || [k(0, 28), k(0.5, 0, 'saida')],
|
|
122
|
+
desfoque: [k(Math.max(0.35, vida - 0.3), 0), k(vida, 6, 'suave')],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
// Texto que se revela palavra a palavra.
|
|
127
|
+
const texto = (t, x, y, { tamanho, peso = 800, cor = TINTA, fonte = F.titulo, alinhamento = 'center', largura: lg, ...resto }) => ({
|
|
128
|
+
tipo: 'texto', texto: t, x, y, ancora: 'centro', tamanho: Math.round(tamanho), peso, cor, fonte, alinhamento,
|
|
129
|
+
largura: lg, entrelinha: 1.08, revelar: String(t).split(' ').map((_, i) => r2(0.08 + i * 0.07)), ...resto,
|
|
130
|
+
});
|
|
131
|
+
const caixaTexto = (t, maxLarg, linhas, max) => {
|
|
132
|
+
const fit = caber(t, { largura: maxLarg, linhas, max: max * base, min: 22 * base, fonte: F.titulo });
|
|
133
|
+
const chars = String(t).length / Math.max(1, fit.linhas);
|
|
134
|
+
const larg = Math.min(maxLarg, chars * fit.tamanho * 0.56);
|
|
135
|
+
return { ...fit, larg, alt: fit.linhas * fit.tamanho * 1.1 };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const CX = W / 2, CY = H / 2;
|
|
139
|
+
let lado = 0;
|
|
140
|
+
partes.forEach((p, i) => {
|
|
141
|
+
const t0 = ini[i], t1 = fim[i];
|
|
142
|
+
const d = t1 - t0;
|
|
143
|
+
const oscila = (lado++ % 2 === 0 ? -1 : 1);
|
|
144
|
+
// O protagonista VIAJA: cada parte fica num lugar diferente da tela.
|
|
145
|
+
const ox = CX + oscila * (deitado ? 200 : 40) * base, oy = CY + (i % 3 - 1) * 60 * base;
|
|
146
|
+
const titulo = p.titulo || p.texto || '';
|
|
147
|
+
let fx, fy, fr, x = ox, y = oy; // forma do protagonista (unidades) e onde ele fica
|
|
148
|
+
// A saída da parte vai até 0,45 s depois do fim, e a primeira peça da
|
|
149
|
+
// próxima entra em 0,2 s: as duas se cruzam no ar.
|
|
150
|
+
const add = (el, atraso) => elementos.push(peca(el, t0, t1 + 0.45, { atraso }));
|
|
151
|
+
|
|
152
|
+
// Imagem sem mídia vira título: moldura vazia é pior que nenhuma.
|
|
153
|
+
const semMidia = p.tipo === 'imagem' && !(imagens[i] || p.src);
|
|
154
|
+
switch (semMidia ? 'titulo' : p.tipo) {
|
|
155
|
+
case 'abertura': {
|
|
156
|
+
fx = fy = px(170 * base); fr = fx / 2; y = oy - 90 * base;
|
|
157
|
+
const tt = caixaTexto(titulo, W * 0.8, 2, 120);
|
|
158
|
+
add(texto(titulo, CX, y + 230 * base, { tamanho: tt.tamanho, largura: W * 0.84 }), 0.35);
|
|
159
|
+
if (p.sub) add(texto(p.sub, CX, y + 230 * base + tt.alt / 2 + 70 * base, { tamanho: 40 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.6);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'numero': {
|
|
163
|
+
const r = 230 * base;
|
|
164
|
+
fx = fy = px(r * 2); fr = fx / 2;
|
|
165
|
+
const n = numeroDe(p.valor);
|
|
166
|
+
const v = String(p.valor || '');
|
|
167
|
+
add({ ...texto(v, x, y, { tamanho: Math.min(170, 900 / Math.max(2, v.length)) * base }), revelar: undefined,
|
|
168
|
+
...(n ? { contar: { ate: n.ate, antes: n.antes, depois: n.depois, casas: n.casas, dur: Math.min(1.4, d * 0.5) } } : {}) }, 0.2);
|
|
169
|
+
if (p.rotulo || titulo) add(texto(p.rotulo || titulo, x, y + r + 80 * base, { tamanho: 48 * base, peso: 600, fonte: F.texto }), 0.5);
|
|
170
|
+
som(t0 + 0.2, 'brilho', 0.3);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case 'lista': case 'passos': case 'grafico': {
|
|
174
|
+
const itens = itensDe(p).slice(0, 6);
|
|
175
|
+
const tt = caixaTexto(titulo || ' ', W * 0.62, 1, 76);
|
|
176
|
+
fx = px(tt.larg + 120 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
|
|
177
|
+
y = CY - (itens.length > 3 ? 250 : 190) * base;
|
|
178
|
+
if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
|
|
179
|
+
const porLinha = deitado ? Math.min(3, itens.length) : 1;
|
|
180
|
+
const cw = deitado ? Math.min(520, (W * 0.84) / porLinha - 30) * 1 : W * 0.8;
|
|
181
|
+
const ch = 150 * base;
|
|
182
|
+
itens.forEach((it, j) => {
|
|
183
|
+
const col = j % porLinha, lin = Math.floor(j / porLinha);
|
|
184
|
+
const n = Math.min(porLinha, itens.length - lin * porLinha);
|
|
185
|
+
const cx = CX + (col - (n - 1) / 2) * (cw + 30), cy = y + 190 * base + lin * (ch + 26 * base) + ch / 2;
|
|
186
|
+
const fit = caber(it, { largura: cw - 70, linhas: 2, max: 40 * base, min: 20 * base, fonte: F.texto });
|
|
187
|
+
const at = r2(0.55 + j * Math.min(0.22, (d - 1.2) / Math.max(1, itens.length)));
|
|
188
|
+
add({
|
|
189
|
+
tipo: 'grupo', x: cx, y: cy, largura: cw, altura: ch, ancora: 'centro', raio: 30,
|
|
190
|
+
fundo: misturarCor(FUNDO, '#ffffff', 0.08), contorno: `1px solid ${misturarCor(FUNDO, '#ffffff', 0.18)}`,
|
|
191
|
+
sombra: '0 24px 50px rgba(0,0,0,0.35)',
|
|
192
|
+
// Sai de dentro do protagonista e gira no eixo Y até o lugar.
|
|
193
|
+
anima: { x: [k(0, x - cx), k(0.55, 0, 'saida')], y: [k(0, y - cy), k(0.55, 0, 'saida')], rotacaoY: [k(0, (col - 1) * 55 || 40), k(0.6, 0, 'saida')],
|
|
194
|
+
escala: [k(0, 0.4), k(0.55, 1, 'saida')] },
|
|
195
|
+
elementos: [
|
|
196
|
+
...(p.tipo === 'passos' ? [{ tipo: 'texto', texto: String(j + 1), x: 46, y: ch / 2, ancora: 'centro', tamanho: 52 * base, peso: 800, fonte: F.titulo, cor: DEST }] : []),
|
|
197
|
+
{ tipo: 'texto', texto: it, x: p.tipo === 'passos' ? 88 : cw / 2, y: ch / 2, ancora: p.tipo === 'passos' ? 'centro-y' : 'centro',
|
|
198
|
+
largura: cw - (p.tipo === 'passos' ? 120 : 70), tamanho: fit.tamanho, peso: 600, fonte: F.texto, cor: TINTA,
|
|
199
|
+
alinhamento: p.tipo === 'passos' ? 'left' : 'center', entrelinha: 1.12 },
|
|
200
|
+
],
|
|
201
|
+
}, at);
|
|
202
|
+
som(t0 + at, 'bolha', 0.22);
|
|
203
|
+
});
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'contraste': {
|
|
207
|
+
const a = p.nao || '', b = p.sim || titulo;
|
|
208
|
+
const ta = caixaTexto(a || ' ', W * 0.7, 2, 90), tb = caixaTexto(b || ' ', W * 0.7, 2, 96);
|
|
209
|
+
fx = px(Math.max(ta.larg, tb.larg) + 150 * base); fy = px(Math.max(ta.alt, tb.alt) + 80 * base); fr = Math.min(fy / 2, px(60));
|
|
210
|
+
const meio = r2(d * 0.45);
|
|
211
|
+
if (a) elementos.push(peca(texto(a, x, y, { tamanho: ta.tamanho, riscado: true, opacidade: 0.75, largura: ta.larg + 60 }), t0, t0 + meio + 0.3, { atraso: 0.2 }));
|
|
212
|
+
if (b) elementos.push(peca(texto(b, x, y, { tamanho: tb.tamanho, largura: tb.larg + 60 }), t0 + meio, t1 + 0.45, { atraso: 0.1 }));
|
|
213
|
+
som(t0 + meio, 'sopro', 0.25);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'citacao': {
|
|
217
|
+
const tt = caixaTexto(`“${titulo}”`, W * 0.66, 3, 70);
|
|
218
|
+
fx = px(tt.larg + 180 * base); fy = px(tt.alt + 150 * base); fr = px(56);
|
|
219
|
+
add(texto(`“${titulo}”`, x, y - (p.autor ? 22 : 0), { tamanho: tt.tamanho, italico: true, peso: 600, largura: tt.larg + 60 }), 0.2);
|
|
220
|
+
if (p.autor) add(texto(`— ${p.autor}`, x, y + tt.alt / 2 + 20 * base, { tamanho: 34 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.4) }), 0.7);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'imagem': {
|
|
224
|
+
const iw = deitado ? W * 0.56 : W * 0.84, ih = iw * 9 / 16;
|
|
225
|
+
fx = px(iw); fy = px(ih); fr = px(40);
|
|
226
|
+
y = oy - (titulo ? 40 * base : 0);
|
|
227
|
+
const m = imagens[i] || (p.src ? { src: p.src, tipo: 'imagem' } : null);
|
|
228
|
+
if (m) {
|
|
229
|
+
add({ tipo: 'grupo', x, y, largura: iw, altura: ih, ancora: 'centro', raio: 40, recorte: true,
|
|
230
|
+
elementos: [{ tipo: m.tipo === 'video' ? 'video' : 'imagem', src: m.src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
|
|
231
|
+
...(m.tipo === 'video' ? { volume: 0 } : {}), anima: { escala: [k(0, 1.12), k(d + 0.4, 1.0, 'suave')] } }] }, 0.2);
|
|
232
|
+
}
|
|
233
|
+
if (titulo) add(texto(titulo, x, y + ih / 2 + 70 * base, { tamanho: 50 * base, peso: 700, largura: iw }), 0.6);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'cta': {
|
|
237
|
+
const tt = caixaTexto(titulo || ' ', W * 0.6, 1, 64);
|
|
238
|
+
fx = px(tt.larg + 160 * base); fy = px(tt.alt + 70 * base); fr = fy / 2;
|
|
239
|
+
y = oy + 40 * base;
|
|
240
|
+
if (marca.logo) add({ tipo: 'imagem', src: marca.logo, x, y: y - 230 * base, largura: 140 * base, altura: 140 * base, ancora: 'centro', ajuste: 'contain' }, 0.2);
|
|
241
|
+
if (titulo) add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 40 }), 0.2);
|
|
242
|
+
if (p.link) add(texto(p.link, x, y + fy * U / 2 + 60 * base, { tamanho: 36 * base, peso: 600, fonte: F.texto, cor: misturarCor(TINTA, DEST, 0.35) }), 0.6);
|
|
243
|
+
// O cursor entra girando e clica no botão.
|
|
244
|
+
const clique = r2(Math.min(d - 0.4, 1.5));
|
|
245
|
+
elementos.push({
|
|
246
|
+
tipo: 'forma', forma: 'traco', caminho: 'M 10 4 L 10 84 L 31 64 L 45 94 L 58 88 L 44 59 L 74 59 Z', x: 0, y: 0, largura: 56 * base, altura: 56 * base,
|
|
247
|
+
cor: '#ffffff', linha: '#14151c', espessura: 4, sombra: '0 10px 12px rgba(0,0,0,0.4)', origem: '10% 4%', de: r2(t0 + 0.4), ate: r2(t1),
|
|
248
|
+
anima: {
|
|
249
|
+
x: [k(0, W * 0.92), k(clique - 0.4, x + fx * U * 0.22, 'saida')], y: [k(0, H * 0.95), k(clique - 0.4, y + 6, 'saida')],
|
|
250
|
+
z: [k(0, 300), k(clique - 0.4, 40, 'saida')], rotacao: [k(0, 150), k(clique - 0.4, 0, 'saida')],
|
|
251
|
+
escala: [k(clique - 0.45, 1), k(clique - 0.4 + 0.08, 0.84, 'suave'), k(clique - 0.4 + 0.26, 1, 'suave')],
|
|
252
|
+
opacidade: [k(0, 0), k(0.2, 1)],
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
som(t0 + clique, 'clique', 0.55);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
default: { // titulo e o que mais vier
|
|
259
|
+
const tt = caixaTexto(titulo || ' ', W * 0.7, 2, 104);
|
|
260
|
+
fx = px(tt.larg + 150 * base); fy = px(tt.alt + 80 * base); fr = Math.min(fy / 2, px(90));
|
|
261
|
+
add(texto(titulo, x, y, { tamanho: tt.tamanho, largura: tt.larg + 60 }), 0.2);
|
|
262
|
+
if (p.sub) add(texto(p.sub, x, y + fy * U / 2 + 58 * base, { tamanho: 38 * base, peso: 500, fonte: F.texto, cor: misturarCor(TINTA, FUNDO, 0.3) }), 0.55);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// O protagonista: deforma no começo da parte, anda até o lugar dela.
|
|
267
|
+
const alvo = forma(Math.min(96, fx), Math.min(96, fy), fr);
|
|
268
|
+
const tIni = r2(Math.max(0, t0 - 0.05)), tFim = r2(t0 + 0.55);
|
|
269
|
+
if (i === 0) trilhaForma.push(k(0, alvo)); else trilhaForma.push(k(tIni, trilhaForma[trilhaForma.length - 1].v), k(tFim, alvo, 'suave'));
|
|
270
|
+
if (i === 0) { trilhaX.push(k(0, x)); trilhaY.push(k(0, y)); } else { trilhaX.push(k(tIni, trilhaX[trilhaX.length - 1].v), k(tFim, x, 'suave')); trilhaY.push(k(tIni, trilhaY[trilhaY.length - 1].v), k(tFim, y, 'suave')); }
|
|
271
|
+
trilhaRot.push(k(r2(t0 + d * 0.5), oscila * 3, 'suave'));
|
|
272
|
+
trilhaEsc.push(k(tIni, 1), k(r2(t0 + 0.3), 1.04, 'suave'), k(tFim, 1, 'suave'));
|
|
273
|
+
if (i > 0) { som(t0, 'sopro', 0.28); som(t0 + 0.05, 'subida', 0.14); }
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── o protagonista, com espessura ────────────────────────────────────
|
|
277
|
+
const protagonista = (fundo, z, extra = {}) => ({
|
|
278
|
+
tipo: 'forma', forma: 'traco', caminho: trilhaForma[0].v, x: 0, y: 0, largura: CAIXA, altura: CAIXA, ancora: 'centro', fundo, ...extra,
|
|
279
|
+
anima: {
|
|
280
|
+
caminho: trilhaForma, x: trilhaX, y: trilhaY, rotacaoY: [k(0, 40), k(0.9, 0, 'saida'), ...trilhaRot.filter((q) => q.t > 0.9)],
|
|
281
|
+
escala: [k(0, 0.2), k(0.8, 1, 'saida'), ...trilhaEsc.filter((q) => q.t > 0.8)],
|
|
282
|
+
z: [k(0, -1400 + z), k(0.8, z, 'saida')],
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const corpo = [protagonista(LADO, -18), protagonista(LUZ, 0, { sombra: '0 30px 40px rgba(0,0,0,0.45)' })];
|
|
286
|
+
|
|
287
|
+
// ── o espaço: partículas em três profundidades ──────────────────────
|
|
288
|
+
const particulas = [];
|
|
289
|
+
for (let i = 0; i < 40; i++) {
|
|
290
|
+
const z = [-1600, -900, -400][i % 3];
|
|
291
|
+
const s = [6, 5, 4][i % 3];
|
|
292
|
+
particulas.push({ tipo: 'forma', forma: 'circulo', x: -900 + (i * 541) % (W + 1800), y: -600 + (i * 313) % (H + 1200), largura: s, altura: s,
|
|
293
|
+
cor: i % 4 === 0 ? DEST : '#ffffff', opacidade: 0.18 + (i % 4) * 0.1,
|
|
294
|
+
anima: { z: [k(0, z)], y: [k(0, 0), k(DUR, -40 - (i % 3) * 30, 'linear')] } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── a câmera orbita: um giro por parte, e nunca para ────────────────
|
|
298
|
+
const cam = { rotacaoY: [k(0, -14)], rotacaoX: [k(0, 7)], z: [k(0, -120)] };
|
|
299
|
+
partes.forEach((_, i) => {
|
|
300
|
+
const meio = r2((ini[i] + fim[i]) / 2);
|
|
301
|
+
cam.rotacaoY.push(k(meio, i % 2 ? 7 : -7, 'suave'));
|
|
302
|
+
cam.rotacaoX.push(k(meio, i % 2 ? -3 : 3, 'suave'));
|
|
303
|
+
cam.z.push(k(meio, 60, 'suave'), k(r2(fim[i]), 0, 'suave'));
|
|
304
|
+
});
|
|
305
|
+
cam.rotacaoY.push(k(DUR, 0, 'suave'));
|
|
306
|
+
cam.rotacaoX.push(k(DUR, 0, 'suave'));
|
|
307
|
+
|
|
308
|
+
// ── o som: a música abaixa quando a voz fala ─────────────────────────
|
|
309
|
+
const volumes = [k(0, 0.05), k(0.4, 0.22)];
|
|
310
|
+
if (temVoz) {
|
|
311
|
+
for (const tp of tempos) volumes.push(k(r2(Math.max(0.41, tp.de - 0.15)), 0.22), k(r2(tp.de), 0.11), k(r2(tp.ate), 0.11), k(r2(tp.ate + 0.35), 0.22));
|
|
312
|
+
}
|
|
313
|
+
volumes.push(k(r2(DUR - 0.6), 0.2), k(DUR, 0));
|
|
314
|
+
som(0.05, 'sopro', 0.3);
|
|
315
|
+
som(DUR - 1.2, 'brilho', 0.35);
|
|
316
|
+
|
|
317
|
+
const plano = {
|
|
318
|
+
formato: { largura: W, altura: H, fps: FPS },
|
|
319
|
+
fundoGeral: FUNDO,
|
|
320
|
+
cenas: [{
|
|
321
|
+
duracao: DUR,
|
|
322
|
+
perspectiva: 1300,
|
|
323
|
+
fundo: { tipo: 'gradiente', radial: true, centro: '38% 42%', cores: [misturarCor(FUNDO, DEST, 0.18), FUNDO] },
|
|
324
|
+
camera: cam,
|
|
325
|
+
elementos: [...particulas, ...corpo, ...elementos],
|
|
326
|
+
}],
|
|
327
|
+
audio: {
|
|
328
|
+
...(narracao ? { narracao } : {}),
|
|
329
|
+
...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
|
|
330
|
+
efeitos,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
return { ok: true, plano, resumo: { cenas: 1, segundos: DUR, partes: partes.length, fps: FPS } };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { montarMotion, forma, CAIXA };
|
package/lib/estudio/plano.js
CHANGED
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
|
|
48
48
|
const fs = require('fs');
|
|
49
49
|
const path = require('path');
|
|
50
|
+
const morfar = require('./morfar.js');
|
|
50
51
|
|
|
51
|
-
const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente']);
|
|
52
|
+
const TIPOS = new Set(['texto', 'forma', 'imagem', 'video', 'icone', 'legenda', 'componente', 'grupo']);
|
|
52
53
|
const FORMAS = new Set(['retangulo', 'circulo', 'linha', 'poligono', 'traco']);
|
|
53
54
|
const AJUSTES = new Set(['cover', 'contain', 'preencher']);
|
|
54
55
|
const TRANSICOES = new Set(['corte', 'esmaecer', 'deslizar', 'zoom', 'giro', 'mascara', 'morph', 'chicote', 'soco', 'flash']);
|
|
@@ -61,7 +62,11 @@ const ANIMAVEIS = new Set([
|
|
|
61
62
|
'opacidade', 'x', 'y', 'z', 'escala', 'escalaX', 'escalaY',
|
|
62
63
|
'rotacao', 'rotacaoX', 'rotacaoY', 'desfoque', 'brilho', 'saturacao',
|
|
63
64
|
'corte', 'raio', 'espacamento',
|
|
65
|
+
// Estes dois não são número: "cor" é "#rrggbb" e "caminho" é o "d" de um
|
|
66
|
+
// SVG. É o que deixa a bolinha virar foguete no mesmo plano (morfar.js).
|
|
67
|
+
'cor', 'caminho',
|
|
64
68
|
]);
|
|
69
|
+
const TEXTUAIS = new Set(['cor', 'caminho']);
|
|
65
70
|
|
|
66
71
|
/* A CÂMERA tem vocabulário próprio, e é de propósito.
|
|
67
72
|
*
|
|
@@ -70,7 +75,8 @@ const ANIMAVEIS = new Set([
|
|
|
70
75
|
* agente escrever zoom de câmera dentro de um elemento — o que dá quase o
|
|
71
76
|
* mesmo resultado na cena de uma coisa só, e nenhum resultado na cena com
|
|
72
77
|
* cinco. Vocabulários separados tornam o erro impossível em vez de sutil. */
|
|
73
|
-
|
|
78
|
+
// rotacaoX/rotacaoY/z: a câmera gira em volta do mundo e avança nele (precisa de `perspectiva` na cena).
|
|
79
|
+
const ANIMAVEIS_CAMERA = new Set(['zoom', 'x', 'y', 'rotacao', 'rotacaoX', 'rotacaoY', 'z']);
|
|
74
80
|
|
|
75
81
|
const SUAVIZACOES = new Set(['linear', 'suave', 'entrada', 'saida', 'elastico', 'salto']);
|
|
76
82
|
|
|
@@ -92,7 +98,7 @@ function medida(v) {
|
|
|
92
98
|
* maior faz o elemento voltar no tempo, e o que se vê é um tremor que
|
|
93
99
|
* ninguém consegue explicar olhando o resultado.
|
|
94
100
|
*/
|
|
95
|
-
function conferirTrilha(caminho, trilha, problemas) {
|
|
101
|
+
function conferirTrilha(caminho, trilha, problemas, prop) {
|
|
96
102
|
if (!Array.isArray(trilha)) {
|
|
97
103
|
problemas.push(erro(caminho, 'a animação de uma propriedade é uma lista de quadros-chave'));
|
|
98
104
|
return;
|
|
@@ -104,7 +110,11 @@ function conferirTrilha(caminho, trilha, problemas) {
|
|
|
104
110
|
if (typeof q.t !== 'number' || !Number.isFinite(q.t) || q.t < 0) {
|
|
105
111
|
problemas.push(erro(onde, 't é o tempo em segundos dentro da cena, a partir de 0'));
|
|
106
112
|
}
|
|
107
|
-
if (
|
|
113
|
+
if (prop === 'cor') {
|
|
114
|
+
if (!morfar.ehCor(q.v)) problemas.push(erro(onde, 'v da cor é "#rrggbb" (ou "#rgb")'));
|
|
115
|
+
} else if (prop === 'caminho') {
|
|
116
|
+
try { morfar.pontos(q.v, 16); } catch (e) { problemas.push(erro(onde, `caminho inválido: ${e.message}`)); }
|
|
117
|
+
} else if (typeof q.v !== 'number' || !Number.isFinite(q.v)) {
|
|
108
118
|
problemas.push(erro(onde, 'v é o valor numérico da propriedade nesse instante'));
|
|
109
119
|
}
|
|
110
120
|
if (q.e && !SUAVIZACOES.has(q.e)) {
|
|
@@ -126,7 +136,7 @@ function conferirAnima(caminho, anima, problemas, permitidas = ANIMAVEIS) {
|
|
|
126
136
|
`não dá para animar "${prop}" aqui. Dá para: ${[...permitidas].join(', ')}`));
|
|
127
137
|
continue;
|
|
128
138
|
}
|
|
129
|
-
conferirTrilha(`${caminho}.${prop}`, trilha, problemas);
|
|
139
|
+
conferirTrilha(`${caminho}.${prop}`, trilha, problemas, prop);
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
142
|
|
|
@@ -193,7 +203,19 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
|
|
|
193
203
|
if (el.forma && !FORMAS.has(el.forma)) {
|
|
194
204
|
problemas.push(erro(`${caminho}.forma`, `forma "${el.forma}" não existe — ${[...FORMAS].join(', ')}`));
|
|
195
205
|
}
|
|
206
|
+
if (el.forma === 'traco') {
|
|
207
|
+
const d = el.caminho || (el.anima && el.anima.caminho && el.anima.caminho[0] && el.anima.caminho[0].v);
|
|
208
|
+
if (!d) problemas.push(erro(`${caminho}.caminho`, 'o traço precisa de "caminho": o "d" de um SVG, na caixa 0 0 100 100'));
|
|
209
|
+
else { try { morfar.pontos(d, 16); } catch (e) { problemas.push(erro(`${caminho}.caminho`, e.message)); } }
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case 'grupo': {
|
|
213
|
+
// Os filhos vivem no tempo do grupo: o 0 deles é o "de" do grupo.
|
|
214
|
+
if (!Array.isArray(el.elementos)) { problemas.push(erro(`${caminho}.elementos`, 'o grupo precisa da lista de elementos dele')); break; }
|
|
215
|
+
const vida = el.ate != null ? Number(el.ate) - Number(el.de || 0) : duracao - Number(el.de || 0);
|
|
216
|
+
el.elementos.forEach((f, k) => conferirElemento(`${caminho}.elementos[${k}]`, f, pasta, problemas, vida));
|
|
196
217
|
break;
|
|
218
|
+
}
|
|
197
219
|
case 'imagem':
|
|
198
220
|
case 'video':
|
|
199
221
|
conferirArquivo(`${caminho}.src`, el.src, pasta, problemas);
|
|
@@ -305,6 +327,25 @@ function quadros(plano) {
|
|
|
305
327
|
return Math.max(1, Math.round(segundos * fps));
|
|
306
328
|
}
|
|
307
329
|
|
|
330
|
+
/**
|
|
331
|
+
* A capa queimada no QUADRO 0.
|
|
332
|
+
*
|
|
333
|
+
* "Discord, X e Slack ignoram a thumbnail" (Arthur, do /brag, 28/09): quem
|
|
334
|
+
* compartilha o mp4 vê o primeiro quadro, não a capa que o YouTube guardou.
|
|
335
|
+
* Então a capa vira o quadro 0, por cima da primeira cena, e some no quadro
|
|
336
|
+
* 1. Não é cena nova: a duração e o áudio ficam como estavam.
|
|
337
|
+
*/
|
|
338
|
+
function queimarCapa(plano, capa) {
|
|
339
|
+
if (!capa || !capa.src || !plano || !Array.isArray(plano.cenas) || !plano.cenas.length) return plano;
|
|
340
|
+
const fps = Number((plano.formato || {}).fps) || 30;
|
|
341
|
+
const [primeira, ...resto] = plano.cenas;
|
|
342
|
+
const quadro0 = {
|
|
343
|
+
tipo: 'imagem', src: capa.src, x: 0, y: 0, largura: '100%', altura: '100%',
|
|
344
|
+
ajuste: capa.ajuste || 'cover', de: 0, ate: 1 / fps,
|
|
345
|
+
};
|
|
346
|
+
return { ...plano, cenas: [{ ...primeira, elementos: [...(primeira.elementos || []), quadro0] }, ...resto] };
|
|
347
|
+
}
|
|
348
|
+
|
|
308
349
|
/** O que existe no plano, em texto curto — para o agente se situar sem reler tudo. */
|
|
309
350
|
function resumo(plano) {
|
|
310
351
|
if (!plano || !Array.isArray(plano.cenas)) return 'plano vazio';
|
|
@@ -325,6 +366,6 @@ function resumo(plano) {
|
|
|
325
366
|
}
|
|
326
367
|
|
|
327
368
|
module.exports = {
|
|
328
|
-
conferir, quadros, resumo,
|
|
369
|
+
conferir, quadros, resumo, queimarCapa,
|
|
329
370
|
TIPOS, FORMAS, ANIMAVEIS, ANIMAVEIS_CAMERA, SUAVIZACOES, TRANSICOES, AJUSTES,
|
|
330
371
|
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* producao.js — o que cada cena PRECISA, separado de QUEM gera cada coisa.
|
|
3
|
+
*
|
|
4
|
+
* "Separar o que cada cena precisa (a fala, a foto, o clipe) de quem gera
|
|
5
|
+
* cada coisa. Assim, trocar Gemini por Edge na voz vira trocar um
|
|
6
|
+
* provider, e o cinema.js não muda." — Arthur, sobre o Hypit (28/09/2026)
|
|
7
|
+
*
|
|
8
|
+
* Só a IDEIA do Hypit entra aqui: a licença dele proíbe embutir o código.
|
|
9
|
+
*
|
|
10
|
+
* ── AS TRÊS PEÇAS ────────────────────────────────────────────────────────
|
|
11
|
+
* necessidades(roteiro) o que o roteiro pede: narração, foto, clipe,
|
|
12
|
+
* música. Não sabe quem vai gerar nada.
|
|
13
|
+
* produzir(pedidos, lista) para cada pedido, tenta os provedores daquele
|
|
14
|
+
* tipo NA ORDEM da lista. Erro, vazio ou
|
|
15
|
+
* indisponível cai no próximo. Registra quem gerou.
|
|
16
|
+
* paraCinema(resultado) devolve no formato que o montarCinema já recebe
|
|
17
|
+
* (narracao, tempos, fotos, trilha).
|
|
18
|
+
*
|
|
19
|
+
* Um provedor é { nome, tipo, disponivel?(), gerar(pedido) }. `gerar` devolve
|
|
20
|
+
* o que produziu, ou null quando não conseguiu. Trocar Gemini por Edge é
|
|
21
|
+
* mudar a ordem da lista: nenhum estilo (cinema, impacto, edição) conhece
|
|
22
|
+
* provedor nenhum.
|
|
23
|
+
*
|
|
24
|
+
* Puro de propósito: a rede e o disco moram nos provedores, então o teste roda
|
|
25
|
+
* sem chave e sem internet.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
// Quantas fotos cada tipo de parte do cinema costuma consumir.
|
|
31
|
+
const FOTOS = { montagem: 3, climax: 4, numeros: 2, escada: 2, dia: 1 };
|
|
32
|
+
|
|
33
|
+
const textoPadrao = (p) => (p.fala != null ? p.fala : (p.linhas || []).join(' '));
|
|
34
|
+
const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
|
|
35
|
+
|
|
36
|
+
/** O que o roteiro pede, sem dizer quem gera. */
|
|
37
|
+
function necessidades(roteiro, { falaDe = textoPadrao } = {}) {
|
|
38
|
+
const partes = (roteiro && roteiro.partes) || [];
|
|
39
|
+
const pedidos = [];
|
|
40
|
+
const falas = [];
|
|
41
|
+
let segundos = 0;
|
|
42
|
+
|
|
43
|
+
partes.forEach((p, i) => {
|
|
44
|
+
if (p.tipo === 'depoimento') {
|
|
45
|
+
for (const c of p.clipes || []) {
|
|
46
|
+
pedidos.push({ tipo: 'clipe', parte: i, src: c.src, de: c.de, ate: c.ate });
|
|
47
|
+
segundos += Math.max(0, (c.ate || 0) - (c.de || 0));
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const fala = falaDe(p);
|
|
52
|
+
if (fala) { falas.push({ parte: i, texto: fala }); segundos += 0.4 + 0.34 * palavras(fala); }
|
|
53
|
+
if (FOTOS[p.tipo]) {
|
|
54
|
+
const termo = [p.antes, p.titulo, ...(p.linhas || []), ...(p.itens || [])].filter(Boolean).join(' ');
|
|
55
|
+
pedidos.push({ tipo: 'foto', parte: i, termo, quantas: FOTOS[p.tipo] });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (falas.length) {
|
|
60
|
+
pedidos.unshift({ tipo: 'narracao', partes: falas.map((f) => f.parte), falas: falas.map((f) => f.texto), idioma: roteiro.idioma || 'pt-BR' });
|
|
61
|
+
}
|
|
62
|
+
pedidos.push({ tipo: 'musica', clima: roteiro.clima || roteiro.estilo || 'neutro', segundos: Math.round(segundos) });
|
|
63
|
+
return pedidos;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cada pedido pela corrente dos provedores do tipo dele, na ordem da lista. */
|
|
67
|
+
async function produzir(pedidos, provedores, { log = () => {} } = {}) {
|
|
68
|
+
const itens = [];
|
|
69
|
+
for (const pedido of pedidos || []) {
|
|
70
|
+
const tentou = [];
|
|
71
|
+
let feito = null;
|
|
72
|
+
for (const pv of (provedores || []).filter((x) => x && x.tipo === pedido.tipo)) {
|
|
73
|
+
if (pv.disponivel && !pv.disponivel()) continue;
|
|
74
|
+
try {
|
|
75
|
+
const r = await pv.gerar(pedido);
|
|
76
|
+
if (r) { feito = { ...pedido, ...r, ok: true, quem: pv.nome, tentou }; break; }
|
|
77
|
+
tentou.push({ nome: pv.nome, motivo: 'não devolveu nada' });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
tentou.push({ nome: pv.nome, motivo: (e && e.message) || String(e) });
|
|
80
|
+
}
|
|
81
|
+
log(`${pedido.tipo}: ${pv.nome} falhou, próximo`);
|
|
82
|
+
}
|
|
83
|
+
itens.push(feito || { ...pedido, ok: false, tentou });
|
|
84
|
+
}
|
|
85
|
+
return { itens, faltou: itens.filter((i) => !i.ok) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** O resultado no formato que o montarCinema já recebe. */
|
|
89
|
+
function paraCinema({ itens = [] } = {}) {
|
|
90
|
+
const ok = itens.filter((i) => i.ok);
|
|
91
|
+
const narr = ok.find((i) => i.tipo === 'narracao');
|
|
92
|
+
const musica = ok.find((i) => i.tipo === 'musica');
|
|
93
|
+
const fotos = ok.filter((i) => i.tipo === 'foto')
|
|
94
|
+
.flatMap((i) => (Array.isArray(i.fotos) ? i.fotos : [{ src: i.src, tipo: 'imagem' }]))
|
|
95
|
+
.filter((f) => f && f.src);
|
|
96
|
+
const creditos = [...new Set(ok.map((i) => i.credito).filter(Boolean))];
|
|
97
|
+
return {
|
|
98
|
+
narracao: narr ? narr.src : null,
|
|
99
|
+
tempos: narr ? narr.tempos || [] : [],
|
|
100
|
+
fotos,
|
|
101
|
+
trilha: musica ? musica.src : null,
|
|
102
|
+
creditos,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { necessidades, produzir, paraCinema, FOTOS };
|