morfeo 0.3.1 → 0.3.4
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 +1 -1
- package/assets/fuentes/Silkscreen.ttf +0 -0
- package/bin/morfeo.mjs +3 -1
- package/index.d.ts +25 -3
- package/lib/config.mjs +10 -0
- package/lib/estructura.mjs +1 -1
- package/lib/palabras.mjs +1 -1
- package/lib/render.mjs +144 -86
- package/lib/salida.mjs +84 -0
- package/package.json +2 -2
- package/python/musica.py +9 -1
- package/remotion/estilo.ts +2 -0
- package/remotion/kit/tres.tsx +8 -3
- package/remotion/motor/Video.tsx +174 -9
- package/remotion/motor/modelo3d.tsx +8 -3
- package/remotion/motor/modos.tsx +120 -0
- package/remotion/motor/subtitulos.tsx +17 -2
- package/remotion/motor/tema.ts +10 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morfeo
|
|
2
2
|
|
|
3
|
-
> **Documentación: [
|
|
3
|
+
> **Documentación: [morfeo-docs-two.vercel.app](https://morfeo-docs-two.vercel.app/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo) · Versiones: [historial](https://morfeo-docs-two.vercel.app/historial)
|
|
4
4
|
|
|
5
5
|
Genera videos verticales animados (Shorts, TikTok, Reels) desde código: escribes las escenas y Morfeo agrega
|
|
6
6
|
la voz, la música, los efectos de sonido, los subtítulos, las animaciones y la miniatura.
|
|
Binary file
|
package/bin/morfeo.mjs
CHANGED
|
@@ -138,7 +138,9 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
138
138
|
const p = await cargarProyecto(proyecto);
|
|
139
139
|
const num = (v) => (v == null || v === true ? undefined : Number(v));
|
|
140
140
|
for (const id of pos) {
|
|
141
|
-
const
|
|
141
|
+
const {salidaDe, ordenarSalida} = await import('../lib/salida.mjs');
|
|
142
|
+
ordenarSalida(p);
|
|
143
|
+
const video = /\.(mp4|mov|webm)$/i.test(id) ? path.resolve(id) : salidaDe(p, id);
|
|
142
144
|
console.log(hoja({video, cols: num(op.cols), filas: num(op.filas), desde: num(op.desde), hasta: num(op.hasta)}));
|
|
143
145
|
}
|
|
144
146
|
return;
|
package/index.d.ts
CHANGED
|
@@ -154,8 +154,30 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
154
154
|
rotuloEn?: Momento;
|
|
155
155
|
rotuloTam?: number;
|
|
156
156
|
/** 'normal' | 'contorno' (blanco con contorno, clave en acento) | 'pastilla' (caja oscura inclinada: ¡CUIDADO!). Por defecto, el del estilo. */
|
|
157
|
-
|
|
157
|
+
/**
|
|
158
|
+
* 'sello': sello de goma inclinado que cae de golpe ('¡SIN LUZ!') · 'paso': titular arriba a la izquierda con
|
|
159
|
+
* insignia numerada (`rotuloPaso`) · 'pixel': letra de videojuego en caja de color (`rotuloColor`: 'verde', 'rojo'…).
|
|
160
|
+
*/
|
|
161
|
+
rotuloEstilo?: 'normal' | 'contorno' | 'pastilla' | 'sello' | 'paso' | 'pixel';
|
|
158
162
|
rotuloInclinar?: number;
|
|
163
|
+
/** Número de la insignia del rótulo 'paso'. */
|
|
164
|
+
rotuloPaso?: number | string;
|
|
165
|
+
/** Color del sello, del paso o de la caja pixel: 'rojo', 'verde', 'azul', 'amarillo', 'naranja', 'gris', 'negro', 'morado' o CSS. */
|
|
166
|
+
rotuloColor?: string;
|
|
167
|
+
/** Altura del rótulo (px de 1920). */
|
|
168
|
+
rotuloY?: number;
|
|
169
|
+
/**
|
|
170
|
+
* Modo de imagen: el mundo, el fondo y las capas cambian a 'rayosx' (cian sobre negro), 'plano' (blueprint azul con
|
|
171
|
+
* rejilla) u 'holograma' (azul translúcido con rejilla y escaneo), con un barrido. Rótulo y subtítulos no cambian.
|
|
172
|
+
* Si la escena siguiente tiene el mismo `modo`, sigue sin volver a barrer.
|
|
173
|
+
*/
|
|
174
|
+
modo?: 'rayosx' | 'plano' | 'holograma';
|
|
175
|
+
/** Palabra o segundos en que entra el modo (por defecto, al inicio de la escena). */
|
|
176
|
+
modoEn?: Momento;
|
|
177
|
+
/** Palabra o segundos en que vuelve a la imagen normal. */
|
|
178
|
+
modoHasta?: Momento;
|
|
179
|
+
/** Invertir la imagen en el modo (fondo claro → oscuro). Por defecto sí en looks claros (3d, maqueta, explicativo, infografia, minimal). */
|
|
180
|
+
modoInvertir?: boolean;
|
|
159
181
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
160
182
|
claves?: string[];
|
|
161
183
|
/** Ajustes del tema solo para esta escena. */
|
|
@@ -183,7 +205,7 @@ export interface Tema {
|
|
|
183
205
|
colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
|
|
184
206
|
fuentes: {titulo: string; texto: string; mono: string};
|
|
185
207
|
tituloMayusculas: boolean;
|
|
186
|
-
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean | 'inicial'; contorno?: number; fondo?: string};
|
|
208
|
+
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean | 'inicial'; contorno?: number; fondo?: string; /** Colores que rotan entre las palabras clave (palabra, pop). */ coloresClave?: string[]};
|
|
187
209
|
transicion: Transicion;
|
|
188
210
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
|
|
189
211
|
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
|
|
@@ -195,7 +217,7 @@ export interface Tema {
|
|
|
195
217
|
vinetaColor?: string;
|
|
196
218
|
};
|
|
197
219
|
/** Rótulo de cada escena: estilo 'normal' | 'contorno' | 'pastilla', inclinación, altura (px) y tamaño. */
|
|
198
|
-
rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla'; inclinar?: number; y?: number; tam?: number};
|
|
220
|
+
rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla' | 'sello' | 'paso' | 'pixel'; inclinar?: number; y?: number; tam?: number; color?: string};
|
|
199
221
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
200
222
|
zoomLento: number;
|
|
201
223
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -22,6 +22,8 @@ const parecidas = (nombre, lista, n = 3) => {
|
|
|
22
22
|
export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
|
|
23
23
|
export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
|
|
24
24
|
export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
25
|
+
export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixel'];
|
|
26
|
+
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
25
27
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
26
28
|
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'barra', 'medidor', 'votacion', 'estado', 'noticia', 'chat', 'onda', 'globo', 'seguir', 'personaje', 'escena3d', 'ventana', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa'];
|
|
27
29
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
@@ -158,6 +160,8 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
158
160
|
concurrencia: Number(process.env.MORFEO_CONCURRENCIA) || r.concurrencia || null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
159
161
|
miniatura: r.miniatura ?? true,
|
|
160
162
|
consejos: r.consejos ?? true,
|
|
163
|
+
// 'titulo' (por defecto): salida/<Título del video>.mp4, listo para subir · 'id': salida/<id>.mp4
|
|
164
|
+
nombreArchivo: r.nombreArchivo ?? 'titulo',
|
|
161
165
|
},
|
|
162
166
|
};
|
|
163
167
|
}
|
|
@@ -311,6 +315,11 @@ function validarVideo(id, v, ctx) {
|
|
|
311
315
|
);
|
|
312
316
|
if (s.capas != null) validarCapas(s.capas, `${n}`, err, ctx.componentes);
|
|
313
317
|
if (s.rotulo != null && typeof s.rotulo !== 'string') err(`${n}: \`rotulo\` debe ser un texto corto ('¿CUÁNTO *SUBIÓ*?')`);
|
|
318
|
+
if (s.rotuloEstilo != null && !ROTULOS.includes(s.rotuloEstilo)) err(`${n}: rotuloEstilo "${s.rotuloEstilo}" no existe. Opciones: ${ROTULOS.join(', ')}`);
|
|
319
|
+
if (s.rotuloPaso != null && s.rotuloEstilo !== 'paso') err(`${n}: \`rotuloPaso\` es para rotuloEstilo: 'paso'`);
|
|
320
|
+
if (s.modo != null) {
|
|
321
|
+
if (!MODOS.includes(s.modo)) err(`${n}: modo "${s.modo}" no existe. Opciones: ${MODOS.join(', ')}`);
|
|
322
|
+
} else if (s.modoEn != null || s.modoHasta != null) err(`${n}: \`modoEn\`/\`modoHasta\` necesitan \`modo\` ('rayosx', 'plano' u 'holograma')`);
|
|
314
323
|
if (s.claves != null && (!Array.isArray(s.claves) || s.claves.some((k) => typeof k !== 'string'))) err(`${n}: \`claves\` debe ser una lista de palabras (['cerebro'])`);
|
|
315
324
|
if (s.toma != null) {
|
|
316
325
|
const tm = s.toma;
|
|
@@ -349,6 +358,7 @@ function validarVideo(id, v, ctx) {
|
|
|
349
358
|
if (clasico) err('`mundo` es del motor de capas: usa un estilo como ilustrado, 3d o mesa (no "datos-curiosos")');
|
|
350
359
|
}
|
|
351
360
|
if (v.serie != null && typeof v.serie !== 'string') err("`serie` debe ser un texto ('GYM CON CIENCIA · PARTE 5')");
|
|
361
|
+
if (v.title != null && typeof v.title !== 'string') err('`title` debe ser un texto (también es el nombre del archivo del video)');
|
|
352
362
|
if (v.calidad != null && !['alta', 'media', 'ligera'].includes(v.calidad)) err("`calidad` debe ser 'alta', 'media' o 'ligera'");
|
|
353
363
|
if (v.tags != null && (!Array.isArray(v.tags) || v.tags.some((x) => typeof x !== 'string'))) err("`tags` debe ser una lista de textos (['curiosidades', 'ciencia'])");
|
|
354
364
|
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
package/lib/estructura.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export function revisarEstructura(v, d) {
|
|
|
106
106
|
// 5) habla de tú
|
|
107
107
|
const todo = cuerpo.map((s) => s.say ?? '').join(' ');
|
|
108
108
|
const n = palabrasDe(todo).length;
|
|
109
|
-
const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|tienes|eres|puedes|sabes)\b/gi) ?? []).length;
|
|
109
|
+
const tu = (todo.match(/\b(tú|tu|tus|te|ti|contigo|imagina|imaginas|tienes|eres|puedes|sabes|sabías|crees|conoces|harías|pagarías)\b/gi) ?? []).length;
|
|
110
110
|
if (n > 40 && tu === 0) add('tu', 'idea', 'No le habla al espectador ni una vez: una frase en segunda persona ("imagina que tú…") lo mete en la historia.');
|
|
111
111
|
return out;
|
|
112
112
|
}
|
package/lib/palabras.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export function revisarPalabras(v, d) {
|
|
|
56
56
|
if (!dichas.length) return;
|
|
57
57
|
const momentos = [];
|
|
58
58
|
// los de la escena misma (rotuloEn, at/click de las clásicas) y los de sus capas
|
|
59
|
-
for (const k of ['rotuloEn', 'at', 'click']) {
|
|
59
|
+
for (const k of ['rotuloEn', 'modoEn', 'modoHasta', 'at', 'click']) {
|
|
60
60
|
const p = palabraDe(s[k]);
|
|
61
61
|
if (p) momentos.push([k, p]);
|
|
62
62
|
}
|
package/lib/render.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import {createRequire} from 'node:module';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {cargarProyecto, ErrorMorfeo} from './config.mjs';
|
|
9
|
-
import {ENTRADA_REMOTION, FUENTES, MODELOS, PY_DIR, modelosListos, pythonVenv, venvListo} from './entorno.mjs';
|
|
9
|
+
import {ENTRADA_REMOTION, FUENTES, MODELOS, PAQUETE, PY_DIR, modelosListos, pythonVenv, venvListo} from './entorno.mjs';
|
|
10
10
|
import {asegurarNavegador} from './navegador.mjs';
|
|
11
11
|
import {generarImagenes} from './imagenes.mjs';
|
|
12
12
|
import {ponerPoses, prepararPersonajes} from './personajes.mjs';
|
|
@@ -15,6 +15,7 @@ import {sintetizarVoces} from './voces.mjs';
|
|
|
15
15
|
import {transcribir, whisperListo} from './whisper.mjs';
|
|
16
16
|
import {consejosEnTexto, revisarEstructura} from './estructura.mjs';
|
|
17
17
|
import {revisarPalabras} from './palabras.mjs';
|
|
18
|
+
import {ordenarSalida, salidaDe} from './salida.mjs';
|
|
18
19
|
|
|
19
20
|
const require = createRequire(import.meta.url);
|
|
20
21
|
|
|
@@ -71,7 +72,13 @@ function huellaMotor() {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
// Remotion calcula la clave del caché a partir de la configuración: meter la huella ahí la invalida
|
|
74
|
-
|
|
75
|
+
// También: los componentes del proyecto pueden importar 'three', 'react' o '@react-three/fiber' aunque no estén instalados
|
|
76
|
+
// en el proyecto (llegan como dependencias de morfeo; con `npm i ../morfeo` o pnpm no quedan en su node_modules).
|
|
77
|
+
const conHuella = (config) => ({
|
|
78
|
+
...config,
|
|
79
|
+
name: `morfeo-${huellaMotor()}`,
|
|
80
|
+
resolve: {...config.resolve, modules: [...(config.resolve?.modules ?? ['node_modules']), path.join(PAQUETE, 'node_modules')]},
|
|
81
|
+
});
|
|
75
82
|
|
|
76
83
|
/**
|
|
77
84
|
* Entrada de Remotion. Sin componentes propios es la del paquete; con ellos, Morfeo escribe
|
|
@@ -128,13 +135,13 @@ export const datosDe = (publico, id) => {
|
|
|
128
135
|
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
129
136
|
};
|
|
130
137
|
|
|
131
|
-
const salidaDe = (p, id) => path.join(p.salida, `${id}.mp4`);
|
|
132
138
|
|
|
133
139
|
/** [{id, titulo, hecho}] en el orden del proyecto. */
|
|
134
140
|
export async function lista({proyecto = process.cwd()} = {}) {
|
|
135
141
|
cargarEnv(proyecto);
|
|
136
142
|
const p = await cargarProyecto(proyecto);
|
|
137
|
-
|
|
143
|
+
ordenarSalida(p);
|
|
144
|
+
return Object.entries(p.videos).map(([id, v]) => ({id, titulo: v.title ?? id, archivo: path.basename(salidaDe(p, id)), hecho: fs.existsSync(salidaDe(p, id))}));
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/** Corre python/preparar.py con el trabajo indicado; su salida va directo a la terminal. */
|
|
@@ -218,6 +225,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
218
225
|
return [];
|
|
219
226
|
}
|
|
220
227
|
}
|
|
228
|
+
// archivos ya hechos con el id o con un título viejo → su nombre actual (sin volver a renderizar)
|
|
229
|
+
ordenarSalida(p, (m) => log(m));
|
|
221
230
|
let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
|
|
222
231
|
const malos = lista.filter((id) => !p.videos[id]);
|
|
223
232
|
if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
|
|
@@ -236,90 +245,97 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
if (!listos.length) return terminarLote(p, loteArchivo, [], fallidos, log);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
|
|
242
|
-
const entrada = entradaDe(p);
|
|
243
|
-
const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
|
|
244
|
-
fs.mkdirSync(p.salida, {recursive: true});
|
|
245
|
-
const chromiumOptions = {gl: p.render.gl};
|
|
246
|
-
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
248
|
+
// un solo render a la vez en esta computadora: dos Chrome usando la GPU al mismo tiempo dejaban en algunos
|
|
249
|
+
// cuadros pedazos de texto de cuadros anteriores (rótulos o subtítulos fantasma)
|
|
247
250
|
const hechos = [];
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
251
|
+
const soltar = await tomarCandado(log);
|
|
252
|
+
try {
|
|
253
|
+
log('Empaquetando la animación...');
|
|
254
|
+
const {bundle} = await import('@remotion/bundler');
|
|
255
|
+
const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
|
|
256
|
+
const entrada = entradaDe(p);
|
|
257
|
+
const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
|
|
258
|
+
fs.mkdirSync(p.salida, {recursive: true});
|
|
259
|
+
const chromiumOptions = {gl: p.render.gl};
|
|
260
|
+
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
261
|
+
// cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
|
|
262
|
+
const nucleos = os.cpus().length;
|
|
263
|
+
const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(10, Math.floor(nucleos * 0.75))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
|
|
264
|
+
const sinTTY = !process.stdout.isTTY;
|
|
265
|
+
for (const [i, id] of listos.entries()) {
|
|
266
|
+
const t = Date.now();
|
|
264
267
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
268
|
+
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
269
|
+
const mp4 = salidaDe(p, id);
|
|
270
|
+
let gl = p.render.gl;
|
|
271
|
+
let concurrencia = concurrenciaBase;
|
|
272
|
+
let composition;
|
|
273
|
+
// hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
|
|
274
|
+
for (let intento = 1; ; intento++) {
|
|
275
|
+
const chromium = {gl};
|
|
276
|
+
let ultimo = -1;
|
|
277
|
+
try {
|
|
278
|
+
composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
|
|
279
|
+
await renderMedia({
|
|
280
|
+
serveUrl,
|
|
281
|
+
composition,
|
|
282
|
+
inputProps,
|
|
283
|
+
browserExecutable,
|
|
284
|
+
chromiumOptions: chromium,
|
|
285
|
+
codec: 'h264',
|
|
286
|
+
crf: crfDe(p, id),
|
|
287
|
+
audioBitrate: '192k',
|
|
288
|
+
imageFormat: 'jpeg',
|
|
289
|
+
jpegQuality: 92,
|
|
290
|
+
concurrency: concurrencia,
|
|
291
|
+
outputLocation: mp4,
|
|
292
|
+
onProgress: ({progress}) => {
|
|
293
|
+
alProgreso?.(id, progress);
|
|
294
|
+
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
295
|
+
const paso = sinTTY ? 4 : 10;
|
|
296
|
+
const n = Math.floor(progress * paso);
|
|
297
|
+
if (n !== ultimo) {
|
|
298
|
+
if (sinTTY) log(`[${i + 1}/${listos.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
|
|
299
|
+
else process.stdout.write(`\r[${i + 1}/${listos.length}] ${id}: video ${n * 10}% `);
|
|
300
|
+
}
|
|
301
|
+
ultimo = n;
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
chromiumOptions.gl = gl;
|
|
305
|
+
break;
|
|
306
|
+
} catch (e) {
|
|
307
|
+
const msg = String(e?.message ?? e);
|
|
308
|
+
const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
|
|
309
|
+
if (!truena || intento >= 3) throw e;
|
|
310
|
+
if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
|
|
311
|
+
else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
|
|
312
|
+
else if (gl === 'angle') gl = 'swangle';
|
|
313
|
+
log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
let jpg = null;
|
|
317
|
+
if (p.render.miniatura) {
|
|
318
|
+
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
319
|
+
jpg = salidaDe(p, id, 'jpg');
|
|
320
|
+
const d = datos[id];
|
|
321
|
+
const e0 = d.escenas[0];
|
|
322
|
+
const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
323
|
+
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
324
|
+
const props = {...inputProps, miniatura: true};
|
|
325
|
+
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
326
|
+
}
|
|
327
|
+
const segundos = Math.round((Date.now() - t) / 1000);
|
|
328
|
+
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
329
|
+
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
330
|
+
hechos.push({id, mp4, jpg, segundos});
|
|
293
331
|
} catch (e) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
|
|
298
|
-
else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
|
|
299
|
-
else if (gl === 'angle') gl = 'swangle';
|
|
300
|
-
log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
|
|
332
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
333
|
+
fallidos.push({id, error: mensajeDe(e)});
|
|
334
|
+
log(`[${i + 1}/${listos.length}] ${id}: ✘ ${mensajeDe(e)}`);
|
|
301
335
|
}
|
|
302
336
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
306
|
-
jpg = path.join(p.salida, `${id}.jpg`);
|
|
307
|
-
const d = datos[id];
|
|
308
|
-
const e0 = d.escenas[0];
|
|
309
|
-
const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
310
|
-
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
311
|
-
const props = {...inputProps, miniatura: true};
|
|
312
|
-
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
313
|
-
}
|
|
314
|
-
const segundos = Math.round((Date.now() - t) / 1000);
|
|
315
|
-
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
316
|
-
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
317
|
-
hechos.push({id, mp4, jpg, segundos});
|
|
318
|
-
} catch (e) {
|
|
319
|
-
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
320
|
-
fallidos.push({id, error: mensajeDe(e)});
|
|
321
|
-
log(`[${i + 1}/${listos.length}] ${id}: ✘ ${mensajeDe(e)}`);
|
|
322
|
-
}
|
|
337
|
+
} finally {
|
|
338
|
+
soltar();
|
|
323
339
|
}
|
|
324
340
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
325
341
|
}
|
|
@@ -332,6 +348,48 @@ export function crfDe(p, id) {
|
|
|
332
348
|
return CRF[v.calidad ?? p.render.calidad ?? (v.mundo?.length ? 'media' : 'alta')] ?? 18;
|
|
333
349
|
}
|
|
334
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Candado de render por computadora (~/.morfeo/render.lock con el pid). Si otro render de Morfeo está corriendo,
|
|
353
|
+
* espera a que termine; si el proceso del candado ya no existe, lo toma. Devuelve la función que lo suelta.
|
|
354
|
+
*/
|
|
355
|
+
async function tomarCandado(log) {
|
|
356
|
+
const {HOME} = await import('./entorno.mjs');
|
|
357
|
+
const archivo = path.join(HOME, 'render.lock');
|
|
358
|
+
fs.mkdirSync(HOME, {recursive: true});
|
|
359
|
+
const vivo = (pid) => {
|
|
360
|
+
try {
|
|
361
|
+
process.kill(pid, 0);
|
|
362
|
+
return true;
|
|
363
|
+
} catch (e) {
|
|
364
|
+
return e.code === 'EPERM';
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
let avisado = false;
|
|
368
|
+
for (;;) {
|
|
369
|
+
try {
|
|
370
|
+
fs.writeFileSync(archivo, String(process.pid), {flag: 'wx'});
|
|
371
|
+
break;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
if (e.code !== 'EEXIST') throw e;
|
|
374
|
+
const pid = Number(fs.readFileSync(archivo, 'utf8')) || 0;
|
|
375
|
+
if (!pid || pid === process.pid || !vivo(pid)) {
|
|
376
|
+
fs.rmSync(archivo, {force: true});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!avisado) log(`Otro render de Morfeo está usando la tarjeta de video (proceso ${pid}); espero a que termine...`);
|
|
380
|
+
avisado = true;
|
|
381
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const soltar = () => {
|
|
385
|
+
try {
|
|
386
|
+
if (Number(fs.readFileSync(archivo, 'utf8')) === process.pid) fs.rmSync(archivo, {force: true});
|
|
387
|
+
} catch {}
|
|
388
|
+
};
|
|
389
|
+
process.once('exit', soltar);
|
|
390
|
+
return soltar;
|
|
391
|
+
}
|
|
392
|
+
|
|
335
393
|
/** Primera línea útil de un error (sin la pila de Remotion o de Python). */
|
|
336
394
|
const mensajeDe = (e) => String(e?.message ?? e).split('\n').find((l) => l.trim()) ?? 'error desconocido';
|
|
337
395
|
|
|
@@ -370,18 +428,18 @@ function escribirResumen(p) {
|
|
|
370
428
|
.filter(([id]) => fs.existsSync(salidaDe(p, id)))
|
|
371
429
|
.map(([id, v]) => {
|
|
372
430
|
const c = creditosDe(p, id);
|
|
373
|
-
return {id, titulo: v.title ?? id, archivo:
|
|
431
|
+
return {id, titulo: v.title ?? id, archivo: path.basename(salidaDe(p, id)), duracion: datosDe(publico, id)?.duracion ?? null, ...(c.length ? {creditos: c} : {}), ...(v.meta ? {meta: v.meta} : {})};
|
|
374
432
|
});
|
|
375
433
|
fs.writeFileSync(path.join(p.salida, 'resumen.json'), JSON.stringify(filas, null, '\t') + '\n');
|
|
376
434
|
// créditos para la descripción del video (las licencias CC BY piden mencionar al autor)
|
|
377
435
|
const conCreditos = filas.filter((f) => f.creditos?.length);
|
|
378
|
-
// <
|
|
436
|
+
// <título>.txt: lo que se copia al publicar (título, descripción, etiquetas y créditos)
|
|
379
437
|
for (const f of filas) {
|
|
380
438
|
const v = p.videos[f.id];
|
|
381
439
|
const tags = (v.tags ?? []).map((x) => `#${String(x).replace(/^#/, '').replace(/\s+/g, '')}`).join(' ');
|
|
382
440
|
const creditos = f.creditos?.length ? ['', 'Créditos:', ...f.creditos.map((c) => `- ${c}`)] : [];
|
|
383
441
|
const lineas = [v.title ?? f.id, '', ...(v.descripcion ? [v.descripcion, ''] : []), ...(tags ? [tags] : []), ...creditos];
|
|
384
|
-
fs.writeFileSync(
|
|
442
|
+
fs.writeFileSync(salidaDe(p, f.id, 'txt'), lineas.join('\n').trim() + '\n');
|
|
385
443
|
}
|
|
386
444
|
if (conCreditos.length)
|
|
387
445
|
fs.writeFileSync(path.join(p.salida, 'creditos.md'), ['# Créditos de recursos', '', ...conCreditos.flatMap((f) => [`## ${f.titulo}`, ...f.creditos.map((c) => `- ${c}`), ''])].join('\n'));
|
package/lib/salida.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Nombres de los archivos terminados. Por defecto, el título del video: YouTube, TikTok e Instagram toman el nombre
|
|
2
|
+
// del archivo como título al subirlo, así que no hay que escribirlo otra vez. `render.nombreArchivo: 'id'` vuelve a
|
|
3
|
+
// usar el id. Los videos hechos con el id (versiones anteriores) se renombran solos, sin volver a renderizar.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const EXTENSIONES = ['mp4', 'jpg', 'txt'];
|
|
8
|
+
// nombres que Windows no permite como archivo
|
|
9
|
+
const RESERVADOS = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
|
10
|
+
|
|
11
|
+
/** Título → nombre de archivo válido en Windows, Mac y Linux (conserva acentos y emojis). */
|
|
12
|
+
export function nombreSeguro(titulo, respaldo = 'video') {
|
|
13
|
+
let s = String(titulo ?? '')
|
|
14
|
+
.normalize('NFC')
|
|
15
|
+
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, ' ') // prohibidos en Windows
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/[. ]+$/g, ''); // Windows no acepta punto ni espacio al final
|
|
19
|
+
// largo máximo prudente (cuenta caracteres, no bytes; los emojis cuentan como uno)
|
|
20
|
+
const chars = [...s];
|
|
21
|
+
if (chars.length > 90) s = chars.slice(0, 90).join('').replace(/[. ]+$/g, '');
|
|
22
|
+
if (!s || RESERVADOS.test(s)) s = respaldo;
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* {id: nombre base} para todos los videos del proyecto. Dos videos con el mismo título se distinguen con " (2)",
|
|
28
|
+
* en el orden del proyecto, así que el nombre de cada uno no cambia entre renders.
|
|
29
|
+
*/
|
|
30
|
+
export function nombresDe(p) {
|
|
31
|
+
const porId = {};
|
|
32
|
+
const usados = new Set();
|
|
33
|
+
for (const [id, v] of Object.entries(p.videos)) {
|
|
34
|
+
const base = p.render?.nombreArchivo === 'id' ? id : nombreSeguro(v?.title, id);
|
|
35
|
+
let nombre = base;
|
|
36
|
+
for (let n = 2; usados.has(nombre.toLowerCase()); n++) nombre = `${base} (${n})`;
|
|
37
|
+
usados.add(nombre.toLowerCase());
|
|
38
|
+
porId[id] = nombre;
|
|
39
|
+
}
|
|
40
|
+
return porId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ruta de un archivo terminado: salidaDe(p, 'mi-video') → salida/<Título>.mp4 */
|
|
44
|
+
export const salidaDe = (p, id, ext = 'mp4') => path.join(p.salida, `${nombresDe(p)[id] ?? id}.${ext}`);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pone los archivos ya hechos bajo su nombre actual: los que tenían el id (versiones anteriores) o un título viejo
|
|
48
|
+
* (registrado en .morfeo/archivos.json). Así cambiar el título renombra el video sin volver a renderizarlo.
|
|
49
|
+
*/
|
|
50
|
+
export function ordenarSalida(p, log = () => {}) {
|
|
51
|
+
const registro = path.join(p.trabajo, 'archivos.json');
|
|
52
|
+
let antes = {};
|
|
53
|
+
try {
|
|
54
|
+
antes = JSON.parse(fs.readFileSync(registro, 'utf8'));
|
|
55
|
+
} catch {}
|
|
56
|
+
const ahora = nombresDe(p);
|
|
57
|
+
if (fs.existsSync(p.salida)) {
|
|
58
|
+
for (const [id, nombre] of Object.entries(ahora)) {
|
|
59
|
+
for (const viejo of [antes[id], id]) {
|
|
60
|
+
if (!viejo || viejo === nombre) continue;
|
|
61
|
+
const de = path.join(p.salida, `${viejo}.mp4`);
|
|
62
|
+
const a = path.join(p.salida, `${nombre}.mp4`);
|
|
63
|
+
if (!fs.existsSync(de) || fs.existsSync(a)) continue;
|
|
64
|
+
for (const ext of EXTENSIONES) {
|
|
65
|
+
const d = path.join(p.salida, `${viejo}.${ext}`);
|
|
66
|
+
if (fs.existsSync(d)) fs.renameSync(d, path.join(p.salida, `${nombre}.${ext}`));
|
|
67
|
+
}
|
|
68
|
+
// la hoja de contacto también sigue al video
|
|
69
|
+
const hoja = path.join(p.salida, `${viejo}-hoja.png`);
|
|
70
|
+
if (fs.existsSync(hoja)) fs.renameSync(hoja, path.join(p.salida, `${nombre}-hoja.png`));
|
|
71
|
+
log(` ${viejo}.mp4 → ${nombre}.mp4`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// resumen.json apunta a los archivos: que siga a los nombres nuevos
|
|
77
|
+
const resumen = path.join(p.salida, 'resumen.json');
|
|
78
|
+
try {
|
|
79
|
+
const filas = JSON.parse(fs.readFileSync(resumen, 'utf8'));
|
|
80
|
+
if (Array.isArray(filas)) fs.writeFileSync(resumen, JSON.stringify(filas.map((f) => (ahora[f.id] ? {...f, archivo: `${ahora[f.id]}.mp4`} : f)), null, '\t') + '\n');
|
|
81
|
+
} catch {}
|
|
82
|
+
fs.mkdirSync(p.trabajo, {recursive: true});
|
|
83
|
+
fs.writeFileSync(registro, JSON.stringify(ahora, null, '\t'));
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"author": "Uriel Gómez",
|
|
8
|
-
"homepage": "https://
|
|
8
|
+
"homepage": "https://morfeo-docs-two.vercel.app/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/UreckChan/morfeo.git"
|
package/python/musica.py
CHANGED
|
@@ -237,7 +237,15 @@ def mix(vid, style, outdir, TIM, SPEC, durs, ev):
|
|
|
237
237
|
for i in range(len(sp["rows"])): add(sfx, 0.2 * whoosh(rng, 0.15), t0 + m["speech"] * 0.55 + i * 0.12)
|
|
238
238
|
if ty == "cta": add(sfx, 0.4 * tick(), t0 + 1.6)
|
|
239
239
|
# motor de capas: cada capa suena cuando entra (las explícitas siempre; las automáticas, más bajas y sin amontonarse)
|
|
240
|
-
if sp.get("rotulo"):
|
|
240
|
+
if sp.get("rotulo"):
|
|
241
|
+
# el sello cae de golpe y la letra pixel suena como pantalla vieja; los demás rótulos entran con un whoosh
|
|
242
|
+
est = sp.get("rotuloEstilo") or ""
|
|
243
|
+
ef, vol = {"sello": ("golpe", 0.9), "pixel": ("tick", 0.8)}.get(est, ("whoosh", 0.5))
|
|
244
|
+
add(sfx, vol * efecto(ef, rng), t0 + momento(sp.get("rotuloEn", 0.12), ev, si, m) - (0.0 if est == "sello" else 0.05))
|
|
245
|
+
# modo de imagen (rayos X, plano, holograma): barrido al entrar, salvo que siga de la escena anterior
|
|
246
|
+
prev = SPEC[si - 1] if si > 0 else None
|
|
247
|
+
if sp.get("modo") and not (prev and prev.get("modo") == sp.get("modo") and prev.get("modoHasta") is None and sp.get("modoEn") is None):
|
|
248
|
+
add(sfx, 0.35 * efecto("whoosh", rng), t0 + momento(sp.get("modoEn", 0), ev, si, m))
|
|
241
249
|
usados = []
|
|
242
250
|
for capa, ef, explicito in sorted(capas_con_sonido(sp), key=lambda x: momento(x[0].get("en"), ev, si, m)):
|
|
243
251
|
tc = t0 + momento(capa.get("en"), ev, si, m)
|
package/remotion/estilo.ts
CHANGED
|
@@ -36,10 +36,12 @@ export const fondoDe = (indice: number) => FONDOS[indice % FONDOS.length];
|
|
|
36
36
|
export const ANTON = 'Anton';
|
|
37
37
|
export const MONT = 'Montserrat';
|
|
38
38
|
export const MONO = 'JetBrains Mono';
|
|
39
|
+
export const PIXEL = 'Silkscreen';
|
|
39
40
|
export const fuentesListas = Promise.all([
|
|
40
41
|
loadFont({family: ANTON, url: staticFile('fuentes/Anton.ttf')}),
|
|
41
42
|
loadFont({family: MONT, url: staticFile('fuentes/Montserrat.ttf'), weight: '100 900'}),
|
|
42
43
|
loadFont({family: MONO, url: staticFile('fuentes/JetBrainsMono.ttf'), weight: '100 800'}),
|
|
44
|
+
loadFont({family: PIXEL, url: staticFile('fuentes/Silkscreen.ttf')}),
|
|
43
45
|
]);
|
|
44
46
|
|
|
45
47
|
// Curvas
|
package/remotion/kit/tres.tsx
CHANGED
|
@@ -85,16 +85,21 @@ const Camara: React.FC<{estado: Estado}> = ({estado}) => {
|
|
|
85
85
|
|
|
86
86
|
const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
|
|
87
87
|
const {gl, scene} = useThree();
|
|
88
|
-
useEffect
|
|
88
|
+
// useLayoutEffect, no useEffect: con useEffect el primer cuadro de cada pestaña de Chrome salía sin reflejos
|
|
89
|
+
// (más oscuro). La textura se crea una vez; la intensidad (que cambia con la hora) se ajusta en cada cuadro.
|
|
90
|
+
useLayoutEffect(() => {
|
|
89
91
|
const pmrem = new THREE.PMREMGenerator(gl);
|
|
90
92
|
const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
91
93
|
scene.environment = tex;
|
|
92
|
-
scene.environmentIntensity = fuerza;
|
|
93
94
|
return () => {
|
|
95
|
+
scene.environment = null;
|
|
94
96
|
tex.dispose();
|
|
95
97
|
pmrem.dispose();
|
|
96
98
|
};
|
|
97
|
-
}, [gl, scene
|
|
99
|
+
}, [gl, scene]);
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
scene.environmentIntensity = fuerza;
|
|
102
|
+
});
|
|
98
103
|
return null;
|
|
99
104
|
};
|
|
100
105
|
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import {AbsoluteFill, Audio, staticFile} from 'remotion';
|
|
4
4
|
import {Avatar, MarcaContexto, useMarca} from '../piezas';
|
|
5
|
-
import {FPS, H, W, ancho, clamp, easeIO, easeOut, lerp, resorte} from '../estilo';
|
|
5
|
+
import {FPS, H, PIXEL, W, ancho, clamp, easeIO, easeOut, lerp, resorte} from '../estilo';
|
|
6
6
|
import type {Datos, Escena} from '../tipos';
|
|
7
7
|
import {Capas} from './capas';
|
|
8
|
+
import {Modo} from './modos';
|
|
8
9
|
import {FondoTema, Post, Textura, Vineta} from './fondo';
|
|
9
10
|
import {capasDe} from './plantillas';
|
|
10
11
|
import {Subtitulos} from './subtitulos';
|
|
@@ -12,6 +13,8 @@ import {mezclar, temaDe, type Tema} from './tema';
|
|
|
12
13
|
import {azar, cuando, partesResaltadas, type CtxEscena} from './util';
|
|
13
14
|
|
|
14
15
|
const TRANSICION = 0.35;
|
|
16
|
+
// estilos con fondo claro (cielo, papel): los modos de imagen invierten para que el fondo quede oscuro
|
|
17
|
+
const LOOKS_CLAROS = ['3d', 'maqueta', 'explicativo', 'infografia', 'minimal'];
|
|
15
18
|
|
|
16
19
|
/** Valor animado de cámara: número fijo o [inicio, fin] a lo largo de la escena. */
|
|
17
20
|
const rango = (v: unknown, k: number, porDefecto: number) => (Array.isArray(v) ? lerp(v[0], v[1], k) : typeof v === 'number' ? v : porDefecto);
|
|
@@ -136,6 +139,9 @@ const Rotulo: React.FC<{e: Escena; t: number; tema: Tema}> = ({e, t, tema}) => {
|
|
|
136
139
|
} catch {}
|
|
137
140
|
const claro = tema.colores.texto !== '#FFFFFF';
|
|
138
141
|
const estilo = e.p.rotuloEstilo ?? tema.rotulo?.estilo ?? 'normal';
|
|
142
|
+
if (estilo === 'sello') return <RotuloSello texto={texto} t={t - t0} tema={tema} e={e} />;
|
|
143
|
+
if (estilo === 'paso') return <RotuloPaso texto={texto} t={t - t0} tema={tema} e={e} />;
|
|
144
|
+
if (estilo === 'pixel') return <RotuloPixel texto={texto} t={t - t0} tema={tema} e={e} />;
|
|
139
145
|
const contorno = estilo === 'contorno' || (estilo === 'normal' && !claro && (S.contorno ?? 0) > 0);
|
|
140
146
|
const grosor = estilo === 'contorno' ? Math.max(8, Math.round(tam * 0.11)) : S.contorno;
|
|
141
147
|
const pastilla = estilo === 'pastilla';
|
|
@@ -167,6 +173,163 @@ const Rotulo: React.FC<{e: Escena; t: number; tema: Tema}> = ({e, t, tema}) => {
|
|
|
167
173
|
);
|
|
168
174
|
};
|
|
169
175
|
|
|
176
|
+
/** Colores con nombre para `rotuloColor` (o cualquier color CSS). */
|
|
177
|
+
const COLORES_ROTULO: Record<string, string> = {rojo: '#E0312B', verde: '#22A04B', azul: '#2F5BEA', amarillo: '#F5B70A', naranja: '#F2711C', gris: '#8A8F98', negro: '#1B1B1F', morado: '#7B4FD6'};
|
|
178
|
+
const colorRotulo = (c: unknown, porDefecto: string) => (typeof c === 'string' && c ? COLORES_ROTULO[c] ?? c : porDefecto);
|
|
179
|
+
|
|
180
|
+
// textura de tinta gastada para el sello: ruido fractal convertido en máscara (huecos donde la tinta no pegó)
|
|
181
|
+
const TINTA = `url("data:image/svg+xml,${encodeURIComponent(
|
|
182
|
+
"<svg xmlns='http://www.w3.org/2000/svg' width='600' height='240'><filter id='f'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.6 2.05'/></filter><rect width='100%' height='100%' filter='url(#f)'/></svg>",
|
|
183
|
+
)}")`;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Sello de goma: texto en mayúsculas con borde doble, inclinado, que cae de golpe (escala 1.7 → 1 en 0,16 s y un
|
|
187
|
+
* pequeño rebote). La tinta tiene huecos como un sello real. `rotuloColor` (rojo por defecto) y `rotuloInclinar`.
|
|
188
|
+
*/
|
|
189
|
+
const RotuloSello: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> = ({texto, t, tema, e}) => {
|
|
190
|
+
const color = colorRotulo(e.p.rotuloColor ?? tema.rotulo?.color, '#E0312B');
|
|
191
|
+
const caida = clamp(t / 0.16);
|
|
192
|
+
const rebote = t > 0.16 ? Math.sin(clamp((t - 0.16) / 0.22) * Math.PI) * 0.05 : 0;
|
|
193
|
+
const escala = lerp(1.7, 1, easeIO(caida)) - rebote;
|
|
194
|
+
const inclinar = e.p.rotuloInclinar ?? tema.rotulo?.inclinar ?? -7;
|
|
195
|
+
let tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 92;
|
|
196
|
+
const limpio = texto.replace(/\*/g, '').toUpperCase();
|
|
197
|
+
try {
|
|
198
|
+
const w = ancho(limpio, tema.fuentes.titulo, tam, 900);
|
|
199
|
+
if (w > 820) tam = Math.max(44, Math.floor((tam * 820) / w));
|
|
200
|
+
} catch {}
|
|
201
|
+
const y = e.p.rotuloY ?? tema.rotulo?.y ?? 1180;
|
|
202
|
+
return (
|
|
203
|
+
<div style={{position: 'absolute', left: 0, width: W, top: y, display: 'flex', justifyContent: 'center', pointerEvents: 'none', opacity: clamp(caida * 4)}}>
|
|
204
|
+
<div
|
|
205
|
+
style={{
|
|
206
|
+
transform: `translate(0, -50%) rotate(${inclinar}deg) scale(${escala})`,
|
|
207
|
+
fontFamily: tema.fuentes.titulo,
|
|
208
|
+
fontWeight: 900,
|
|
209
|
+
fontSize: tam,
|
|
210
|
+
lineHeight: 1,
|
|
211
|
+
letterSpacing: tam * 0.03,
|
|
212
|
+
color,
|
|
213
|
+
textTransform: 'uppercase',
|
|
214
|
+
padding: `${tam * 0.2}px ${tam * 0.34}px ${tam * 0.16}px`,
|
|
215
|
+
border: `${Math.max(5, tam * 0.08)}px solid ${color}`,
|
|
216
|
+
borderRadius: tam * 0.12,
|
|
217
|
+
outline: `${Math.max(2, tam * 0.03)}px solid ${color}`,
|
|
218
|
+
outlineOffset: -Math.max(9, tam * 0.15),
|
|
219
|
+
background: 'rgba(255,255,255,0.82)',
|
|
220
|
+
boxShadow: '0 10px 26px rgba(0,0,0,0.22)',
|
|
221
|
+
WebkitMaskImage: TINTA,
|
|
222
|
+
maskImage: TINTA,
|
|
223
|
+
WebkitMaskSize: '600px 240px',
|
|
224
|
+
whiteSpace: 'nowrap',
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{limpio}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Titular de paso: arriba a la izquierda, en dos renglones, con `*clave*` en color y una insignia con el número del
|
|
235
|
+
* paso (`rotuloPaso: 3`). Entra deslizándose de la izquierda. Para explicadores "paso a paso".
|
|
236
|
+
*/
|
|
237
|
+
const RotuloPaso: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> = ({texto, t, tema, e}) => {
|
|
238
|
+
const k = easeIO(clamp(t / 0.35));
|
|
239
|
+
const color = colorRotulo(e.p.rotuloColor ?? tema.rotulo?.color, tema.colores.acento);
|
|
240
|
+
const tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 64;
|
|
241
|
+
const y = e.p.rotuloY ?? tema.rotulo?.y ?? 250;
|
|
242
|
+
const n = e.p.rotuloPaso;
|
|
243
|
+
return (
|
|
244
|
+
<div style={{position: 'absolute', left: 56, right: 56, top: y, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 24, pointerEvents: 'none'}}>
|
|
245
|
+
<div style={{clipPath: `inset(0 ${(1 - k) * 100}% 0 0)`, transform: `translateX(${(1 - k) * -40}px)`, maxWidth: n != null ? 800 : 968}}>
|
|
246
|
+
<span
|
|
247
|
+
style={{
|
|
248
|
+
fontFamily: tema.fuentes.titulo,
|
|
249
|
+
fontWeight: 900,
|
|
250
|
+
fontSize: tam,
|
|
251
|
+
lineHeight: 1.02,
|
|
252
|
+
color: '#FFFFFF',
|
|
253
|
+
textTransform: 'uppercase',
|
|
254
|
+
WebkitTextStroke: `${Math.round(tam * 0.1)}px #15151A`,
|
|
255
|
+
paintOrder: 'stroke fill',
|
|
256
|
+
textShadow: '0 5px 0 rgba(0,0,0,0.25)',
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
{partesResaltadas(texto).map(([p, r], i) => (
|
|
260
|
+
<span key={i} style={r ? {color} : undefined}>
|
|
261
|
+
{p}
|
|
262
|
+
</span>
|
|
263
|
+
))}
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
{n != null ? (
|
|
267
|
+
<div
|
|
268
|
+
style={{
|
|
269
|
+
flex: 'none',
|
|
270
|
+
minWidth: tam * 0.95,
|
|
271
|
+
height: tam * 0.95,
|
|
272
|
+
padding: `0 ${tam * 0.2}px`,
|
|
273
|
+
borderRadius: tam * 0.16,
|
|
274
|
+
background: '#15151A',
|
|
275
|
+
border: '4px solid #FFFFFF',
|
|
276
|
+
color: '#FFFFFF',
|
|
277
|
+
fontFamily: tema.fuentes.titulo,
|
|
278
|
+
fontWeight: 900,
|
|
279
|
+
fontSize: tam * 0.62,
|
|
280
|
+
display: 'flex',
|
|
281
|
+
alignItems: 'center',
|
|
282
|
+
justifyContent: 'center',
|
|
283
|
+
transform: `scale(${resorte(clamp((t - 0.15) / 0.3), 0.35)})`,
|
|
284
|
+
boxShadow: '0 8px 18px rgba(0,0,0,0.3)',
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
{String(n)}
|
|
288
|
+
</div>
|
|
289
|
+
) : null}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Rótulo pixel: letra de videojuego en una caja de color (verde = bien, rojo = mal; `rotuloColor`). Las letras
|
|
296
|
+
* aparecen de una en una, como en una pantalla vieja. Arriba por defecto.
|
|
297
|
+
*/
|
|
298
|
+
const RotuloPixel: React.FC<{texto: string; t: number; tema: Tema; e: Escena}> = ({texto, t, tema, e}) => {
|
|
299
|
+
const fondo = colorRotulo(e.p.rotuloColor ?? tema.rotulo?.color, '#2F5BEA');
|
|
300
|
+
const limpio = texto.replace(/\*/g, '').toUpperCase();
|
|
301
|
+
const letras = [...limpio];
|
|
302
|
+
const vistas = Math.min(letras.length, Math.floor(t / 0.035) + 1);
|
|
303
|
+
let tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 64;
|
|
304
|
+
try {
|
|
305
|
+
const w = ancho(limpio, PIXEL, tam, 400);
|
|
306
|
+
if (w > 900) tam = Math.max(30, Math.floor((tam * 900) / w));
|
|
307
|
+
} catch {}
|
|
308
|
+
const y = e.p.rotuloY ?? tema.rotulo?.y ?? 230;
|
|
309
|
+
return (
|
|
310
|
+
<div style={{position: 'absolute', left: 0, width: W, top: y, display: 'flex', justifyContent: 'center', pointerEvents: 'none'}}>
|
|
311
|
+
<div
|
|
312
|
+
style={{
|
|
313
|
+
transform: 'translateY(-50%)',
|
|
314
|
+
fontFamily: PIXEL,
|
|
315
|
+
fontSize: tam,
|
|
316
|
+
lineHeight: 1.15,
|
|
317
|
+
color: '#FFFFFF',
|
|
318
|
+
background: fondo,
|
|
319
|
+
padding: `${tam * 0.18}px ${tam * 0.3}px ${tam * 0.12}px`,
|
|
320
|
+
border: '4px solid rgba(255,255,255,0.9)',
|
|
321
|
+
boxShadow: '6px 6px 0 rgba(0,0,0,0.45)',
|
|
322
|
+
whiteSpace: 'nowrap',
|
|
323
|
+
imageRendering: 'pixelated',
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
<span>{letras.slice(0, vistas).join('')}</span>
|
|
327
|
+
<span style={{visibility: 'hidden'}}>{letras.slice(vistas).join('')}</span>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
170
333
|
/** Pastilla fija de la serie arriba al centro: "GYM CON CIENCIA · PARTE 5". */
|
|
171
334
|
const Serie: React.FC<{texto: string; tema: Tema}> = ({texto, tema}) => {
|
|
172
335
|
const claro = tema.colores.texto !== '#FFFFFF';
|
|
@@ -244,15 +407,17 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
244
407
|
}
|
|
245
408
|
return (
|
|
246
409
|
<AbsoluteFill style={{background: '#000', overflow: 'hidden'}}>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
<
|
|
410
|
+
<Modo d={d} si={si} t={t} T={T} claro={LOOKS_CLAROS.includes(d.estilo ?? '') || tema.colores.texto !== '#FFFFFF'}>
|
|
411
|
+
{conMundo ? <Mundo d={d} si={si} t={t} T={T} tema={tema} /> : null}
|
|
412
|
+
{enTransicion && prev ? (
|
|
413
|
+
<AbsoluteFill style={estiloPrevio}>
|
|
414
|
+
<EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} />
|
|
415
|
+
</AbsoluteFill>
|
|
416
|
+
) : null}
|
|
417
|
+
<AbsoluteFill style={estiloActual}>
|
|
418
|
+
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} sinFondo={conMundo} />
|
|
251
419
|
</AbsoluteFill>
|
|
252
|
-
|
|
253
|
-
<AbsoluteFill style={estiloActual}>
|
|
254
|
-
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} sinFondo={conMundo} />
|
|
255
|
-
</AbsoluteFill>
|
|
420
|
+
</Modo>
|
|
256
421
|
{destello > 0 ? <AbsoluteFill style={{background: '#fff', opacity: destello}} /> : null}
|
|
257
422
|
<Post tema={tema} />
|
|
258
423
|
{/* la textura va quieta, fuera de la cámara: si se moviera con el zoom, cada cuadro sería distinto y el video pesaría 10 veces más */}
|
|
@@ -96,16 +96,21 @@ const Glb: React.FC<{src: string; levantar?: boolean}> = ({src, levantar}) => {
|
|
|
96
96
|
/** Reflejos de un cuarto iluminado: sin esto, los metales (monedas, lingotes) se ven negros. */
|
|
97
97
|
const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
|
|
98
98
|
const {gl, scene} = useThree();
|
|
99
|
-
useEffect
|
|
99
|
+
// useLayoutEffect, no useEffect: con useEffect el primer cuadro de cada pestaña de Chrome salía sin reflejos
|
|
100
|
+
// (más oscuro). La textura se crea una vez; la intensidad (que cambia con la hora) se ajusta en cada cuadro.
|
|
101
|
+
useLayoutEffect(() => {
|
|
100
102
|
const pmrem = new THREE.PMREMGenerator(gl);
|
|
101
103
|
const tex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
102
104
|
scene.environment = tex;
|
|
103
|
-
scene.environmentIntensity = fuerza;
|
|
104
105
|
return () => {
|
|
106
|
+
scene.environment = null;
|
|
105
107
|
tex.dispose();
|
|
106
108
|
pmrem.dispose();
|
|
107
109
|
};
|
|
108
|
-
}, [gl, scene
|
|
110
|
+
}, [gl, scene]);
|
|
111
|
+
useLayoutEffect(() => {
|
|
112
|
+
scene.environmentIntensity = fuerza;
|
|
113
|
+
});
|
|
109
114
|
return null;
|
|
110
115
|
};
|
|
111
116
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Modos de imagen por escena: la imagen (mundo, fondo y capas) cambia a rayos X, plano azul (blueprint) u holograma
|
|
2
|
+
// justo en una palabra, como en los videos que "abren" el cuerpo o una máquina para explicar lo de adentro.
|
|
3
|
+
// Se hace con filtros y capas de mezcla sobre lo ya dibujado: funciona igual en 2D, en 3D (el canvas también se
|
|
4
|
+
// filtra) y con componentes propios, sin dibujar nada dos veces.
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import {AbsoluteFill} from 'remotion';
|
|
7
|
+
import {H, W, clamp, easeIO} from '../estilo';
|
|
8
|
+
import type {Datos} from '../tipos';
|
|
9
|
+
import {azar, cuando, type CtxEscena} from './util';
|
|
10
|
+
|
|
11
|
+
export const MODOS = ['rayosx', 'plano', 'holograma'] as const;
|
|
12
|
+
export type ModoImagen = (typeof MODOS)[number];
|
|
13
|
+
|
|
14
|
+
const ENTRADA = 0.4; // s del barrido de entrada
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Modo activo en el instante `t` de la escena `si` y cuánto lleva (k de 0 a 1 = barrido de entrada).
|
|
18
|
+
* Si la escena anterior terminó en el mismo modo y esta no dice `modoEn`, sigue sin volver a barrer.
|
|
19
|
+
*/
|
|
20
|
+
export function modoDe(d: Datos, si: number, t: number): {modo: ModoImagen; k: number; sale: number} | null {
|
|
21
|
+
const e = d.escenas[si];
|
|
22
|
+
const modo = e?.p.modo as ModoImagen | undefined;
|
|
23
|
+
if (!modo || !MODOS.includes(modo)) return null;
|
|
24
|
+
const ctx: CtxEscena = {t, dur: e.dur, habla: e.habla, palabras: e.palabras ?? [], tema: {} as any, semilla: ''};
|
|
25
|
+
const prev = si > 0 ? d.escenas[si - 1] : null;
|
|
26
|
+
const sigue = prev?.p.modo === modo && prev.p.modoHasta == null && e.p.modoEn == null;
|
|
27
|
+
const t0 = sigue ? -ENTRADA : cuando(e.p.modoEn ?? 0, ctx, 0);
|
|
28
|
+
if (t < t0) return null;
|
|
29
|
+
// `modoHasta`: vuelve a la imagen normal en esa palabra (con el mismo barrido al revés)
|
|
30
|
+
let sale = 0;
|
|
31
|
+
if (e.p.modoHasta != null) {
|
|
32
|
+
const t1 = cuando(e.p.modoHasta, ctx, e.dur);
|
|
33
|
+
if (t >= t1 + ENTRADA) return null;
|
|
34
|
+
sale = clamp((t - t1) / ENTRADA);
|
|
35
|
+
}
|
|
36
|
+
return {modo, k: clamp((t - t0) / ENTRADA), sale};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Filtro CSS de cada modo, con su intensidad `q` (0 = imagen normal, 1 = modo completo). En looks claros (cielo,
|
|
41
|
+
* papel) se invierte la imagen para que el fondo quede oscuro y lo sólido brille; en looks oscuros no hace falta.
|
|
42
|
+
*/
|
|
43
|
+
function filtro(modo: ModoImagen, q: number, invertir: boolean): string {
|
|
44
|
+
if (q <= 0) return 'none';
|
|
45
|
+
const inv = invertir ? q : 0;
|
|
46
|
+
if (modo === 'rayosx') return `grayscale(${q}) invert(${0.92 * inv}) contrast(${1 + (invertir ? 0.45 : 0.6) * q}) brightness(${1 + (invertir ? -0.08 : 0.18) * q})`;
|
|
47
|
+
if (modo === 'plano') return `grayscale(${q}) invert(${inv}) contrast(${1 + 0.9 * q}) brightness(${1 + 0.1 * q})`;
|
|
48
|
+
return `grayscale(${q}) invert(${0.88 * inv}) contrast(${1 + 0.35 * q}) brightness(${1 + 0.1 * q})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TINTE: Record<ModoImagen, {color: string; mezcla: React.CSSProperties['mixBlendMode']}> = {
|
|
52
|
+
// lo claro queda cian brillante sobre negro
|
|
53
|
+
rayosx: {color: '#38D4FF', mezcla: 'color'},
|
|
54
|
+
// fondo azul de plano con líneas blancas: el azul entra por "screen" (lo negro se vuelve azul, lo blanco sigue blanco)
|
|
55
|
+
plano: {color: '#1747B8', mezcla: 'screen'},
|
|
56
|
+
// azul translúcido sobre oscuro, como proyectado
|
|
57
|
+
holograma: {color: '#3D8BFF', mezcla: 'color'},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Rejilla fina + gruesa (plano y holograma). */
|
|
61
|
+
const Rejilla: React.FC<{color: string; fuerza: number; paso?: number}> = ({color, fuerza, paso = 60}) => (
|
|
62
|
+
<AbsoluteFill
|
|
63
|
+
style={{
|
|
64
|
+
opacity: fuerza,
|
|
65
|
+
backgroundImage: [
|
|
66
|
+
`linear-gradient(${color} 2px, transparent 2px)`,
|
|
67
|
+
`linear-gradient(90deg, ${color} 2px, transparent 2px)`,
|
|
68
|
+
`linear-gradient(${color} 1px, transparent 1px)`,
|
|
69
|
+
`linear-gradient(90deg, ${color} 1px, transparent 1px)`,
|
|
70
|
+
].join(','),
|
|
71
|
+
backgroundSize: `${paso * 5}px ${paso * 5}px, ${paso * 5}px ${paso * 5}px, ${paso}px ${paso}px, ${paso}px ${paso}px`,
|
|
72
|
+
backgroundPosition: '-1px -1px',
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** Envuelve la imagen del video (mundo + escena) y le aplica el modo de la escena actual. */
|
|
78
|
+
export const Modo: React.FC<{d: Datos; si: number; t: number; T: number; claro: boolean; children: React.ReactNode}> = ({d, si, t, T, claro, children}) => {
|
|
79
|
+
const m = modoDe(d, si, t);
|
|
80
|
+
if (!m) return <>{children}</>;
|
|
81
|
+
const invertir = typeof d.escenas[si].p.modoInvertir === 'boolean' ? d.escenas[si].p.modoInvertir : claro;
|
|
82
|
+
const {modo, k, sale} = m;
|
|
83
|
+
const q = easeIO(k) * (1 - easeIO(sale));
|
|
84
|
+
const tinte = TINTE[modo];
|
|
85
|
+
// línea de barrido: baja por la pantalla al entrar y sube al salir; tapa el paso intermedio del filtro
|
|
86
|
+
const barrido = k < 1 ? easeIO(k) : sale > 0 ? 1 - easeIO(sale) : -1;
|
|
87
|
+
const parpadeo = modo === 'holograma' ? 0.94 + 0.06 * azar(`holo-${Math.floor(T * 15)}`) : 1;
|
|
88
|
+
const banda = ((T * 0.35) % 1.3) - 0.15; // banda de escaneo del holograma, cada ~3,7 s
|
|
89
|
+
return (
|
|
90
|
+
<AbsoluteFill style={{isolation: 'isolate'}}>
|
|
91
|
+
<AbsoluteFill style={{filter: filtro(modo, q, invertir)}}>{children}</AbsoluteFill>
|
|
92
|
+
<AbsoluteFill style={{background: tinte.color, mixBlendMode: tinte.mezcla, opacity: (modo === 'plano' ? 1 : 0.9) * q}} />
|
|
93
|
+
{modo === 'plano' ? <Rejilla color="rgba(255,255,255,0.3)" fuerza={q} /> : null}
|
|
94
|
+
{modo === 'holograma' ? (
|
|
95
|
+
<>
|
|
96
|
+
<Rejilla color="rgba(120,200,255,0.30)" fuerza={q * parpadeo} paso={48} />
|
|
97
|
+
<AbsoluteFill style={{opacity: q * 0.18, backgroundImage: 'repeating-linear-gradient(0deg, rgba(160,220,255,0.6) 0 2px, transparent 2px 6px)'}} />
|
|
98
|
+
<div style={{position: 'absolute', left: 0, width: W, top: banda * H, height: 160, opacity: q * 0.35, background: 'linear-gradient(transparent, rgba(140,210,255,0.55), transparent)'}} />
|
|
99
|
+
</>
|
|
100
|
+
) : null}
|
|
101
|
+
{modo === 'rayosx' ? (
|
|
102
|
+
// borde oscuro y grano fino de placa
|
|
103
|
+
<AbsoluteFill style={{opacity: q, background: 'radial-gradient(ellipse 75% 60% at 50% 50%, transparent 55%, rgba(0,8,20,0.75) 100%)'}} />
|
|
104
|
+
) : null}
|
|
105
|
+
{barrido >= 0 ? (
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
position: 'absolute',
|
|
109
|
+
left: 0,
|
|
110
|
+
width: W,
|
|
111
|
+
top: barrido * H - 3,
|
|
112
|
+
height: 6,
|
|
113
|
+
background: modo === 'plano' ? '#FFFFFF' : '#9BE8FF',
|
|
114
|
+
boxShadow: `0 0 28px 10px ${modo === 'plano' ? 'rgba(255,255,255,0.6)' : 'rgba(80,210,255,0.7)'}`,
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
) : null}
|
|
118
|
+
</AbsoluteFill>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -36,6 +36,20 @@ function frases(palabras: Palabra[], max: number): Palabra[][] {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Con `coloresClave`, cada palabra clave de la escena toma el siguiente color de la lista (la 1.ª rojo, la 2.ª naranja…).
|
|
41
|
+
* Cuenta las claves dichas antes que `w` en la escena, con la misma regla de `claveDe` palabra por palabra.
|
|
42
|
+
*/
|
|
43
|
+
function colorRotativo(colores: string[] | undefined, palabras: Palabra[], w: Palabra, claves?: string[]): string | undefined {
|
|
44
|
+
if (!colores?.length) return undefined;
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const p of palabras) {
|
|
47
|
+
if (p === w || p[1] >= w[1]) break;
|
|
48
|
+
if (claveDe([p], claves) === 0) n++;
|
|
49
|
+
}
|
|
50
|
+
return colores[n % colores.length];
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
/** Índice de la palabra clave de la frase (-1 = ninguna). */
|
|
40
54
|
function claveDe(g: Palabra[], claves?: string[]): number {
|
|
41
55
|
if (claves?.length) {
|
|
@@ -80,9 +94,10 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
80
94
|
const [w] = g[0];
|
|
81
95
|
// con una palabra por frase, la clave se decide sobre toda la escena: las marcadas, las cifras y las largas
|
|
82
96
|
const acento = claveDe([g[0]], claves) === 0;
|
|
97
|
+
const colorClave = acento ? colorRotativo(S.coloresClave, palabras, g[0], claves) ?? tema.colores.acento : S.color;
|
|
83
98
|
return (
|
|
84
99
|
<div style={{position: 'absolute', left: 40, width: W - 80, top: S.y, transform: `translateY(-50%) scale(${resorte(entrada, 0.25)})`, textAlign: 'center'}}>
|
|
85
|
-
<span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color:
|
|
100
|
+
<span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color: colorClave, WebkitTextStroke: S.contorno === 0 ? undefined : `${S.contorno ?? 9}px #000`, paintOrder: 'stroke fill', textShadow: S.contorno === 0 ? '0 3px 3px rgba(0,0,0,0.45), 0 6px 22px rgba(0,0,0,0.35)' : '0 6px 0 rgba(0,0,0,0.35)'}}>
|
|
86
101
|
{texto(w)}
|
|
87
102
|
</span>
|
|
88
103
|
</div>
|
|
@@ -133,7 +148,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
133
148
|
fontWeight: 900,
|
|
134
149
|
fontSize: tam,
|
|
135
150
|
lineHeight: 1.12,
|
|
136
|
-
color: i === clave ? tema.colores.acento : S.color,
|
|
151
|
+
color: i === clave ? colorRotativo(S.coloresClave, palabras, g[i], claves) ?? tema.colores.acento : S.color,
|
|
137
152
|
WebkitTextStroke: `${S.contorno ?? 10}px #000`,
|
|
138
153
|
paintOrder: 'stroke fill',
|
|
139
154
|
textShadow: '0 6px 0 rgba(0,0,0,0.4)',
|
package/remotion/motor/tema.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type Tema = {
|
|
|
49
49
|
pendiente: string; // palabras por decir
|
|
50
50
|
resalte: string; // caja de la palabra actual ('' = sin caja)
|
|
51
51
|
maxPalabras: number;
|
|
52
|
+
/** Colores que rotan entre las palabras clave (`palabra` y `pop`): ['#FF3B30', '#FF9F0A', '#FFD60A', '#34C759']. */
|
|
53
|
+
coloresClave?: string[];
|
|
52
54
|
};
|
|
53
55
|
transicion: 'corte' | 'fundido' | 'deslizar' | 'zoom' | 'barrido' | 'empuje' | 'mascara' | 'destello';
|
|
54
56
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. Sin esto: 0.5 / 0.35 / 0.35 y 'suave'. */
|
|
@@ -60,13 +62,19 @@ export type Tema = {
|
|
|
60
62
|
desenfoqueBordes?: number; // px de blur arriba y abajo (efecto maqueta / profundidad)
|
|
61
63
|
vinetaColor?: string;
|
|
62
64
|
};
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Rótulo grande de cada escena: 'normal' (color del texto), 'contorno' (blanco con contorno oscuro, clave en acento),
|
|
67
|
+
* 'pastilla' (caja oscura inclinada), 'sello' (sello de goma con borde que cae de golpe), 'paso' (titular arriba a la
|
|
68
|
+
* izquierda con insignia numerada) o 'pixel' (letra pixel en una caja de color, arriba).
|
|
69
|
+
*/
|
|
70
|
+
rotulo?: {estilo?: EstiloRotulo; inclinar?: number; y?: number; tam?: number; color?: string};
|
|
65
71
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
66
72
|
/** Movimiento suave de cámara por defecto en cada escena (zoom de 1 a este valor). */
|
|
67
73
|
zoomLento: number;
|
|
68
74
|
};
|
|
69
75
|
|
|
76
|
+
export type EstiloRotulo = 'normal' | 'contorno' | 'pastilla' | 'sello' | 'paso' | 'pixel';
|
|
77
|
+
|
|
70
78
|
const SUB_BASE = {tipo: 'karaoke' as const, y: 330, tam: 54, maxPalabras: 7};
|
|
71
79
|
|
|
72
80
|
export const ESTILOS: Record<string, Tema> = {
|