morfeo 0.6.1 → 0.6.3
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/bin/morfeo.mjs +31 -15
- package/index.d.ts +3 -3
- package/lib/calidad.mjs +3 -1
- package/lib/config.mjs +1 -1
- package/lib/estructura.mjs +1 -1
- package/lib/render.mjs +70 -2
- package/package.json +1 -1
- package/plantilla/AGENTS.md +3 -2
- package/python/musica.py +12 -0
- package/remotion/kit/ambientes.tsx +227 -2
- package/remotion/kit/tres.tsx +2 -2
- package/remotion/motor/Video.tsx +4 -1
- package/remotion/motor/anotaciones.tsx +1 -0
- package/remotion/motor/plantillas.tsx +47 -7
- package/remotion/motor/tema.ts +20 -3
package/bin/morfeo.mjs
CHANGED
|
@@ -21,6 +21,8 @@ Videos
|
|
|
21
21
|
render [id...] Genera los videos que faltan (o los que indiques)
|
|
22
22
|
--todos regenera todos
|
|
23
23
|
--solo-fallidos reintenta solo los que fallaron en el último lote
|
|
24
|
+
--cambiados solo los que cambiaron (guion o tus componentes) desde su último render
|
|
25
|
+
--revisar al terminar cada uno, saca su hoja y lo revisa (un solo paso en vez de tres)
|
|
24
26
|
--escenas 5-7 solo esas escenas (para corregir una toma sin rehacer todo) + su hoja
|
|
25
27
|
--gpu usa la tarjeta de video (más rápido; si falla, quítalo)
|
|
26
28
|
previa <id> Solo el gancho (~8 s) para aprobarlo antes del render completo: --hasta 6
|
|
@@ -119,10 +121,18 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
119
121
|
const {render} = await import('../lib/render.mjs');
|
|
120
122
|
const escenas = op.escenas != null && op.escenas !== true ? String(op.escenas).split(/[-,]/).map(Number) : null;
|
|
121
123
|
if (escenas && (escenas.some((x) => !Number.isInteger(x) || x < 1) || pos.length !== 1)) throw new ErrorMorfeo('Uso: npx morfeo render mi-video --escenas 5 (o 5-7)');
|
|
122
|
-
const hechos = await render({proyecto, ids: pos, todos: !!op.todos || pos[0] === 'todos', soloFallidos: !!op.soloFallidos, escenas: escenas ? [escenas[0], escenas[escenas.length - 1]] : null});
|
|
124
|
+
const hechos = await render({proyecto, ids: pos[0] === 'todos' ? [] : pos, todos: !!op.todos || pos[0] === 'todos', cambiados: !!op.cambiados, soloFallidos: !!op.soloFallidos, escenas: escenas ? [escenas[0], escenas[escenas.length - 1]] : null});
|
|
123
125
|
if (escenas) {
|
|
124
126
|
const {hoja} = await import('../lib/hoja.mjs');
|
|
125
127
|
for (const h of hechos ?? []) console.log(hoja({video: h.mp4, cols: 5, filas: 2}));
|
|
128
|
+
} else if (op.revisar) {
|
|
129
|
+
const {hoja} = await import('../lib/hoja.mjs');
|
|
130
|
+
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
131
|
+
const p = await cargarProyecto(proyecto);
|
|
132
|
+
for (const h of hechos ?? []) {
|
|
133
|
+
console.log(hoja({video: h.mp4}));
|
|
134
|
+
await revisarVideo(p, h.id);
|
|
135
|
+
}
|
|
126
136
|
}
|
|
127
137
|
return;
|
|
128
138
|
}
|
|
@@ -145,25 +155,13 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
145
155
|
}
|
|
146
156
|
case 'revisar': {
|
|
147
157
|
const {cargarProyecto} = await import('../lib/config.mjs');
|
|
148
|
-
const {
|
|
158
|
+
const {REGLAS} = await import('../lib/estructura.mjs');
|
|
149
159
|
const p = await cargarProyecto(proyecto);
|
|
150
160
|
const ids = pos.length ? pos : Object.keys(p.videos);
|
|
151
|
-
const fs = await import('node:fs');
|
|
152
161
|
let total = 0;
|
|
153
162
|
for (const id of ids) {
|
|
154
163
|
if (!p.videos[id]) throw new ErrorMorfeo(`No existe el video ${id}. Usa: npx morfeo lista`);
|
|
155
|
-
|
|
156
|
-
const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
|
|
157
|
-
const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
158
|
-
const c = revisarEstructura(p.videos[id], d);
|
|
159
|
-
// con el MP4 ya hecho: cuadros negros, tapados, planos o quietos (--sin-cuadros lo salta)
|
|
160
|
-
if (!op.sinCuadros) {
|
|
161
|
-
const {salidaDe} = await import('../lib/salida.mjs');
|
|
162
|
-
const {revisarCuadros} = await import('../lib/calidad.mjs');
|
|
163
|
-
c.push(...(await revisarCuadros(salidaDe(p, id), d)));
|
|
164
|
-
}
|
|
165
|
-
total += c.length;
|
|
166
|
-
console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
|
|
164
|
+
total += await revisarVideo(p, id, !!op.sinCuadros);
|
|
167
165
|
}
|
|
168
166
|
if (total) console.log(`\nReglas completas: ${REGLAS.map((r) => r.regla.toLowerCase()).join(' · ')}`);
|
|
169
167
|
return;
|
|
@@ -256,6 +254,24 @@ Pruébalo: npx morfeo render ${pos[0]} y revísalo: npx morfeo hoja ${pos[0]
|
|
|
256
254
|
}
|
|
257
255
|
}
|
|
258
256
|
|
|
257
|
+
/** Revisa un video (estructura + cuadros del MP4 si existe), lo imprime y devuelve cuántos avisos tuvo. */
|
|
258
|
+
async function revisarVideo(p, id, sinCuadros = false) {
|
|
259
|
+
const fs = await import('node:fs');
|
|
260
|
+
const {revisarEstructura, consejosEnTexto} = await import('../lib/estructura.mjs');
|
|
261
|
+
// tiempos reales si ya se preparó la voz (después de un render); si no, solo el texto
|
|
262
|
+
const f = path.join(p.trabajo, 'public', 'v', id, 'datos.json');
|
|
263
|
+
const d = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null;
|
|
264
|
+
const c = revisarEstructura(p.videos[id], d);
|
|
265
|
+
// con el MP4 ya hecho: cuadros negros, tapados, planos o quietos (--sin-cuadros lo salta)
|
|
266
|
+
if (!sinCuadros) {
|
|
267
|
+
const {salidaDe} = await import('../lib/salida.mjs');
|
|
268
|
+
const {revisarCuadros} = await import('../lib/calidad.mjs');
|
|
269
|
+
c.push(...(await revisarCuadros(salidaDe(p, id), d)));
|
|
270
|
+
}
|
|
271
|
+
console.log(c.length ? `${id}${d ? '' : ' (sin tiempos: renderízalo para revisar el ritmo)'}\n${consejosEnTexto(c)}` : `${id} ✔`);
|
|
272
|
+
return c.length;
|
|
273
|
+
}
|
|
274
|
+
|
|
259
275
|
async function guion(pos, op, proyecto) {
|
|
260
276
|
const {cargarEnv} = await import('../lib/render.mjs');
|
|
261
277
|
const {guionConIA, guionSinIA, guardarVideo} = await import('../lib/guion.mjs');
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Tipos de Morfeo: dan autocompletado en morfeo.config.mjs y videos.mjs (también en JS).
|
|
2
2
|
|
|
3
3
|
/** '#RRGGBB' o [r, g, b] */
|
|
4
|
-
export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
|
|
4
|
+
export type Ambiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle';
|
|
5
5
|
/** Postproceso 3D: 'cine' (foco + brillo + viñeta), 'miniatura' (tilt-shift), 'suave', false o ajustes propios. */
|
|
6
6
|
export type Post3D = 'cine' | 'miniatura' | 'suave' | false | {foco?: boolean | number; brillo?: number; vinieta?: number; miniatura?: number};
|
|
7
7
|
export type Color = string | [number, number, number];
|
|
@@ -87,7 +87,7 @@ export type Capa = CapaBase &
|
|
|
87
87
|
| {tipo: 'fuente'; texto: string; etiqueta?: string; tam?: number; color?: string; colorTexto?: string}
|
|
88
88
|
| {tipo: 'seccion'; texto: string; tam?: number; color?: string}
|
|
89
89
|
| {tipo: 'insignia'; texto: string | number; tam?: number; fondo?: string; colorTexto?: string; inclinar?: number}
|
|
90
|
-
| {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number}
|
|
90
|
+
| {tipo: 'sello'; texto: string; tam?: number; color?: string; inclinar?: number; /** caja detrás (sobre fondos claros) */ fondo?: string}
|
|
91
91
|
| {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
|
|
92
92
|
| {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
|
|
93
93
|
| {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
|
|
@@ -297,7 +297,7 @@ export interface Video {
|
|
|
297
297
|
* El componente recibe además `escena` (1, 2, 3...), `tEscena`, `inicios` y `camara` (para paralaje).
|
|
298
298
|
*/
|
|
299
299
|
/**
|
|
300
|
-
* Ambiente 3D listo como mundo del video: 'cuarto-gamer' | 'sala' | 'arcade' (o {nombre, persona, post}).
|
|
300
|
+
* Ambiente 3D listo como mundo del video: 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle' (o {nombre, persona, post}).
|
|
301
301
|
* Sirve en cualquier estilo del motor de capas; cada escena elige su `plano` o toma otro ángulo sola.
|
|
302
302
|
*/
|
|
303
303
|
ambiente?: Ambiente | {nombre: Ambiente; persona?: boolean | {ropa?: string; pelo?: string; piel?: string}; reaccionar?: boolean; post?: Post3D};
|
package/lib/calidad.mjs
CHANGED
|
@@ -149,7 +149,9 @@ export async function revisarCuadros(mp4, d) {
|
|
|
149
149
|
// medido: escenas sin nada sobre fondo oscuro → media 13–15 y la cubeta más oscura con 97 % del cuadro;
|
|
150
150
|
// tele o pared pegadas a la cámara → desviación 12–16 con media > 130; los videos buenos del canal: desviación ≥ 27
|
|
151
151
|
negro: (s) => (s.media < 16 && s.desv < 9) || (s.media < 25 && s.dom > 0.9),
|
|
152
|
-
|
|
152
|
+
// (el segundo caso pide además un color dominante: una escena de día con cielo pastel también tiene desviación
|
|
153
|
+
// baja, ~17, pero su color más repetido no pasa del 25 %)
|
|
154
|
+
tapado: (s) => (s.dom > 0.58 && s.luzDom > 45) || (s.desv < 18 && s.media > 60 && s.dom > 0.4),
|
|
153
155
|
plano: (s) => s.desv < 14 && s.media >= 25 && s.media <= 60,
|
|
154
156
|
};
|
|
155
157
|
const TEXTO = {
|
package/lib/config.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const ROTULOS = ['normal', 'contorno', 'pastilla', 'sello', 'paso', 'pixe
|
|
|
28
28
|
export const MODOS = ['rayosx', 'plano', 'holograma'];
|
|
29
29
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
30
30
|
export const TIPOS_CAPA = ['imagen', 'video', 'texto', 'contador', 'chip', 'etiqueta', 'numero', 'tarjeta', 'telefono', 'forma', 'icono', 'emoji', 'marca', 'particulas', 'grupo', 'modelo', 'componente', 'ambiente', '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', 'bloqueo', 'llamada', 'recuento', 'capitulo', 'vigilancia', 'cintillo', 'fecha', 'calendario', 'dividida', 'tierra', 'linea'];
|
|
31
|
-
export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina'];
|
|
31
|
+
export const AMBIENTES = ['cuarto-gamer', 'sala', 'arcade', 'taller', 'oficina', 'tienda', 'calle'];
|
|
32
32
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
33
33
|
export const FORMAS_3D = ['moneda', 'billete', 'lingote', 'cubo', 'esfera', 'corazon', 'estrella', 'rayo', 'diamante', 'trofeo', 'cohete', 'foco', 'reloj', 'planeta', 'arbol', 'nube', 'regalo', 'llave', 'candado', 'casa', 'libro', 'globo', 'medalla', 'escudo', 'pin', 'tuberia', 'muro', 'viga', 'columna', 'escalera', 'losa', 'tanque', 'suelo'];
|
|
34
34
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
package/lib/estructura.mjs
CHANGED
|
@@ -129,7 +129,7 @@ export function revisarEstructura(v, d) {
|
|
|
129
129
|
const capas = (s.capas ?? []).filter(Boolean);
|
|
130
130
|
if (!conMundo && (s.type ?? 'capas') === 'capas' && !capas.length && !s.rotulo) {
|
|
131
131
|
vacias++;
|
|
132
|
-
add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade', 'taller', 'oficina').`);
|
|
132
|
+
add('vacia', 'aviso', `Escena ${i + 1}: solo el subtítulo sobre el fondo. Agrega una capa o dale al video un \`ambiente: 'cuarto-gamer'\` (o 'sala', 'arcade', 'taller', 'oficina', 'tienda', 'calle').`);
|
|
133
133
|
}
|
|
134
134
|
if (capas.length && capas.every((c) => SOLO_TEXTO.has(c.tipo))) soloTexto++;
|
|
135
135
|
for (const c of capas) tipos.set(c.tipo, (tipos.get(c.tipo) ?? 0) + 1);
|
package/lib/render.mjs
CHANGED
|
@@ -230,7 +230,7 @@ export function usaWebGL(p, id) {
|
|
|
230
230
|
* @param {{proyecto?: string, ids?: string[], todos?: boolean, log?: (m: string) => void, alProgreso?: (id: string, p: number) => void}} o
|
|
231
231
|
* @returns {Promise<{id: string, mp4: string, jpg: string | null, segundos: number}[]>}
|
|
232
232
|
*/
|
|
233
|
-
export async function render({proyecto = process.cwd(), ids, todos = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
|
|
233
|
+
export async function render({proyecto = process.cwd(), ids, todos = false, cambiados = false, soloFallidos = false, previa = null, animatica = false, escenas = null, log = console.log, alProgreso} = {}) {
|
|
234
234
|
// animática: todo el video a 1/3 de tamaño y menor calidad (para aprobar tomas y ritmo en segundos)
|
|
235
235
|
// escenas: [a, b] (1 = la primera) solo ese tramo, en tamaño completo
|
|
236
236
|
const parcial = !!(animatica || escenas);
|
|
@@ -250,15 +250,37 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
250
250
|
}
|
|
251
251
|
// archivos ya hechos con el id o con un título viejo → su nombre actual (sin volver a renderizar)
|
|
252
252
|
ordenarSalida(p, (m) => log(m));
|
|
253
|
-
let lista = ids?.length ? ids : todos ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
|
|
253
|
+
let lista = ids?.length ? ids : todos || cambiados ? Object.keys(p.videos) : Object.keys(p.videos).filter((id) => !fs.existsSync(salidaDe(p, id)));
|
|
254
254
|
const malos = lista.filter((id) => !p.videos[id]);
|
|
255
255
|
if (malos.length) throw new ErrorMorfeo(`No existen: ${malos.join(', ')}. Usa: npx morfeo lista`);
|
|
256
256
|
if (!lista.length) {
|
|
257
257
|
log('No hay videos pendientes. Para regenerar: npx morfeo render --todos (o npx morfeo render <id>)');
|
|
258
258
|
return [];
|
|
259
259
|
}
|
|
260
|
+
// --cambiados: solo los videos cuyo guion o componentes del proyecto cambiaron desde su último render. Se decide
|
|
261
|
+
// con el guion, antes de preparar el audio (prepararlo para 30 videos tarda más que renderizar uno).
|
|
262
|
+
// La versión de Morfeo no cuenta (rehacería todo en cada actualización): para eso está --todos.
|
|
263
|
+
const huellas = leerHuellas(p);
|
|
264
|
+
const delProyecto = parcial || previa ? '' : huellaProyecto(p);
|
|
265
|
+
const huellaDe = (id) => crypto.createHash('sha1').update(delProyecto).update(JSON.stringify(p.videos[id], (k, v) => (typeof v === 'function' ? String(v) : v))).digest('hex').slice(0, 16);
|
|
266
|
+
if (cambiados && !ids?.length) {
|
|
267
|
+
const antes = lista.length;
|
|
268
|
+
// la primera vez no hay huella de los videos ya hechos: se dan por buenos y se anota la de ahora
|
|
269
|
+
let nuevas = 0;
|
|
270
|
+
for (const id of lista)
|
|
271
|
+
if (huellas[id] == null && fs.existsSync(salidaDe(p, id))) {
|
|
272
|
+
huellas[id] = huellaDe(id);
|
|
273
|
+
nuevas++;
|
|
274
|
+
}
|
|
275
|
+
if (nuevas) guardarHuellas(p, huellas);
|
|
276
|
+
lista = lista.filter((id) => huellas[id] !== huellaDe(id) || !fs.existsSync(salidaDe(p, id)));
|
|
277
|
+
log(lista.length ? `${lista.length} de ${antes} video(s) cambiaron: ${lista.join(', ')}` : `Ningún video cambió desde su último render (${antes} al día).`);
|
|
278
|
+
if (!lista.length) return [];
|
|
279
|
+
}
|
|
260
280
|
if (!whisperListo()) log('(Whisper no está instalado: subtítulos repartidos por frase. Para tiempos exactos: npx morfeo instalar --whisper)');
|
|
281
|
+
const tPrep = Date.now();
|
|
261
282
|
const {publico, marca, extras, datos, fallidos} = await preparar({proyecto, ids: lista, log, _p: p, continuar: true});
|
|
283
|
+
const segPrep = (Date.now() - tPrep) / 1000;
|
|
262
284
|
const listos = lista.filter((id) => datos[id]);
|
|
263
285
|
// estructura: consejos medidos en videos virales (no detienen el render; se apagan con render.consejos: false)
|
|
264
286
|
if (p.render.consejos !== false)
|
|
@@ -277,12 +299,16 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
277
299
|
const {bundle} = await import('@remotion/bundler');
|
|
278
300
|
const {renderMedia, renderStill, selectComposition} = await import('@remotion/renderer');
|
|
279
301
|
const entrada = entradaDe(p);
|
|
302
|
+
const tBundle = Date.now();
|
|
280
303
|
const serveUrl = await bundle({entryPoint: entrada, publicDir: publico, rootDir: entrada === ENTRADA_REMOTION ? path.dirname(ENTRADA_REMOTION) : p.proyecto, webpackOverride: conHuella});
|
|
304
|
+
const segBundle = (Date.now() - tBundle) / 1000;
|
|
281
305
|
fs.mkdirSync(p.salida, {recursive: true});
|
|
282
306
|
const chromiumOptions = {gl: p.render.gl};
|
|
283
307
|
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
284
308
|
// cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
|
|
285
309
|
const nucleos = os.cpus().length;
|
|
310
|
+
// MORFEO_CONCURRENCIA=n: fija los cuadros en paralelo sin tocar el config (para medir en tu máquina)
|
|
311
|
+
if (process.env.MORFEO_CONCURRENCIA) p.render.concurrencia = Math.max(1, Number(process.env.MORFEO_CONCURRENCIA) || 1);
|
|
286
312
|
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))));
|
|
287
313
|
const sinTTY = !process.stdout.isTTY;
|
|
288
314
|
for (const [i, id] of listos.entries()) {
|
|
@@ -317,6 +343,8 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
317
343
|
audioBitrate: '192k',
|
|
318
344
|
imageFormat: 'jpeg',
|
|
319
345
|
jpegQuality: 92,
|
|
346
|
+
// 'faster': ~10 % menos tiempo que 'medium' y, con el mismo CRF, sin diferencia visible
|
|
347
|
+
x264Preset: 'faster',
|
|
320
348
|
concurrency: concurrencia,
|
|
321
349
|
outputLocation: mp4,
|
|
322
350
|
...(previa ? {frameRange: [0, Math.min(composition.durationInFrames - 1, Math.round(finPrevia * composition.fps))]} : {}),
|
|
@@ -346,6 +374,7 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
346
374
|
log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
|
|
347
375
|
}
|
|
348
376
|
}
|
|
377
|
+
const segVideo = (Date.now() - t) / 1000;
|
|
349
378
|
let jpg = null;
|
|
350
379
|
if (p.render.miniatura && !previa && !parcial) {
|
|
351
380
|
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
@@ -371,7 +400,13 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
371
400
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
372
401
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
373
402
|
log(`[${i + 1}/${listos.length}] ${id}: listo en ${segundos} s → ${path.relative(p.proyecto, mp4)} `);
|
|
403
|
+
// desglose para saber qué optimizar (MORFEO_TIEMPOS=1): audio y empaquetado son de todo el lote
|
|
404
|
+
if (process.env.MORFEO_TIEMPOS) log(` tiempos: audio ${segPrep.toFixed(1)} s (lote) · empaquetar ${segBundle.toFixed(1)} s (lote) · video ${segVideo.toFixed(1)} s · miniatura y portada ${(segundos - segVideo).toFixed(1)} s`);
|
|
374
405
|
hechos.push({id, mp4, jpg, segundos});
|
|
406
|
+
if (!previa && !parcial) {
|
|
407
|
+
huellas[id] = huellaDe(id);
|
|
408
|
+
guardarHuellas(p, huellas);
|
|
409
|
+
}
|
|
375
410
|
} catch (e) {
|
|
376
411
|
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
377
412
|
fallidos.push({id, error: mensajeDe(e)});
|
|
@@ -385,6 +420,39 @@ export async function render({proyecto = process.cwd(), ids, todos = false, solo
|
|
|
385
420
|
return terminarLote(p, loteArchivo, hechos, fallidos, log);
|
|
386
421
|
}
|
|
387
422
|
|
|
423
|
+
// Huella de cada video al renderizarlo (.morfeo/huellas.json): con ella `render --cambiados` sabe cuáles rehacer.
|
|
424
|
+
const archivoHuellas = (p) => path.join(p.trabajo, 'huellas.json');
|
|
425
|
+
function leerHuellas(p) {
|
|
426
|
+
try {
|
|
427
|
+
return JSON.parse(fs.readFileSync(archivoHuellas(p), 'utf8'));
|
|
428
|
+
} catch {
|
|
429
|
+
return {};
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function guardarHuellas(p, h) {
|
|
433
|
+
fs.mkdirSync(p.trabajo, {recursive: true});
|
|
434
|
+
fs.writeFileSync(archivoHuellas(p), JSON.stringify(h, null, '\t'));
|
|
435
|
+
}
|
|
436
|
+
/** Componentes y config del proyecto (tsx/ts/jsx y morfeo.config.mjs): si cambian, cambia cómo se ve el video. */
|
|
437
|
+
function huellaProyecto(p) {
|
|
438
|
+
const partes = [];
|
|
439
|
+
const recorrer = (dir, n = 0) => {
|
|
440
|
+
if (n > 4) return;
|
|
441
|
+
for (const e of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
442
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'guiones') continue;
|
|
443
|
+
const f = path.join(dir, e.name);
|
|
444
|
+
if (path.resolve(f) === path.resolve(p.salida)) continue;
|
|
445
|
+
if (e.isDirectory()) recorrer(f, n + 1);
|
|
446
|
+
else if (/\.(tsx?|jsx)$/.test(e.name) || e.name === 'morfeo.config.mjs') {
|
|
447
|
+
const st = fs.statSync(f);
|
|
448
|
+
partes.push(`${e.name}:${st.size}:${Math.round(st.mtimeMs)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
recorrer(p.proyecto);
|
|
453
|
+
return crypto.createHash('sha1').update(partes.join('|')).digest('hex').slice(0, 8);
|
|
454
|
+
}
|
|
455
|
+
|
|
388
456
|
const CRF = {alta: 18, media: 21, ligera: 24};
|
|
389
457
|
/** Cuadros [inicio, fin] de las escenas a..b (1 = la primera). */
|
|
390
458
|
function tramoEscenas(d, [a, b], composition) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|
package/plantilla/AGENTS.md
CHANGED
|
@@ -43,8 +43,9 @@ la persona te corrija algo que deba valer para los siguientes videos, ofrécele
|
|
|
43
43
|
(tiene 3 ganchos de reserva en `hooks`).
|
|
44
44
|
6. **Aprueba tomas y ritmo barato:** `npx morfeo animatica <id>` (todo el video a 1/3 de tamaño + su hoja). Si una
|
|
45
45
|
toma está mal, arréglala y vuelve a pedir solo esas escenas: `npx morfeo render <id> --escenas 5-6`.
|
|
46
|
-
7. **Renderiza:** `npx morfeo render <id
|
|
47
|
-
|
|
46
|
+
7. **Renderiza y revisa en un paso:** `npx morfeo render <id> --revisar` (video + hoja + revisión). Tras cambiar varios
|
|
47
|
+
guiones, `npx morfeo render --cambiados --revisar` rehace solo los que cambiaron.
|
|
48
|
+
8. **Revisa antes de entregar** (si no usaste `--revisar`):
|
|
48
49
|
- `npx morfeo revisar <id>`: estructura, que la voz haya dicho el guion, que nada quede bajo los botones de la app,
|
|
49
50
|
el riesgo de "diapositiva" y, en el MP4, cuadros negros, tapados (cámara pegada a algo), lisos o quietos.
|
|
50
51
|
- `npx morfeo hoja <id> --zona`: mira la hoja de contacto (es una imagen); lo pintado en rojo es lo que tapan las apps.
|
package/python/musica.py
CHANGED
|
@@ -335,6 +335,18 @@ def cama(nombre, N, rng):
|
|
|
335
335
|
n = int(0.012 * SR); g = int(rng.random() * (N - n))
|
|
336
336
|
x[g:g + n] += 0.02 * hp(rng.standard_normal(n), 2500) * np.exp(-np.arange(n) / (0.002 * SR))
|
|
337
337
|
return x
|
|
338
|
+
if nombre == "tienda":
|
|
339
|
+
x = lp(hp(rng.standard_normal(N), 250), 1300) * 0.025 * (0.6 + 0.4 * np.abs(np.sin(2 * np.pi * 0.6 * t))) + zumbido * 0.7
|
|
340
|
+
for _ in range(int(N / SR / 2)): # la tele de demostración: blips lejanos
|
|
341
|
+
add(x, 0.01 * lp(_tono(nf(74 + int(rng.random() * 12)), 0.1, 20), 2500), rng.random() * (N / SR - 0.2))
|
|
342
|
+
return x
|
|
343
|
+
if nombre == "calle":
|
|
344
|
+
viento = lp(rng.standard_normal(N), 350) * 0.05 * (0.6 + 0.4 * np.sin(2 * np.pi * 0.11 * t))
|
|
345
|
+
x = lluvia(0.06) + viento
|
|
346
|
+
for _ in range(max(1, int(N / SR / 9))): # un coche que pasa a lo lejos
|
|
347
|
+
d = 3.0; n = int(d * SR); tt = np.arange(n) / SR
|
|
348
|
+
add(x, 0.035 * lp(rng.standard_normal(n), 500) * np.sin(np.pi * tt / d) ** 2, rng.random() * max(0.1, N / SR - d))
|
|
349
|
+
return x
|
|
338
350
|
if nombre == "arcade":
|
|
339
351
|
x = lp(rng.standard_normal(N), 700) * 0.05 + zumbido
|
|
340
352
|
for _ in range(int(N / SR * 3)): # maquinitas sonando por todos lados
|
|
@@ -13,7 +13,7 @@ import {Persona3D} from './mundo3d';
|
|
|
13
13
|
import type {Expresion, Pose} from './personas';
|
|
14
14
|
import {Bloque, Lienzo3D, type Etiqueta3D, type Post3D, type Toma3D, type V3} from './tres';
|
|
15
15
|
|
|
16
|
-
export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina';
|
|
16
|
+
export type NombreAmbiente = 'cuarto-gamer' | 'sala' | 'arcade' | 'taller' | 'oficina' | 'tienda' | 'calle';
|
|
17
17
|
export type Reaccion = {en: number; expresion?: Expresion; pose?: Pose};
|
|
18
18
|
|
|
19
19
|
type Def = {
|
|
@@ -627,6 +627,192 @@ const SetOficina: React.FC<{t: number}> = ({t}) => (
|
|
|
627
627
|
</group>
|
|
628
628
|
);
|
|
629
629
|
|
|
630
|
+
|
|
631
|
+
/** Caja de juego de frente (portada de color con una franja), para exhibidores. */
|
|
632
|
+
const CajaJuego: React.FC<{pos: V3; i: number; rot?: V3}> = ({pos, i, rot}) => {
|
|
633
|
+
const c = ['#E5484D', '#3D7BFF', '#F2B632', '#30A46C', '#8B5BD6', '#FF8A3D'][i % 6];
|
|
634
|
+
return (
|
|
635
|
+
<group position={pos} rotation={rot ? [(rot[0] * Math.PI) / 180, (rot[1] * Math.PI) / 180, (rot[2] * Math.PI) / 180] : undefined}>
|
|
636
|
+
<Bloque pos={[0, 0, 0]} tam={[0.26, 0.36, 0.04]} color={c} />
|
|
637
|
+
<Bloque pos={[0, 0.13, 0.022]} tam={[0.26, 0.06, 0.004]} color="#F4F1EA" />
|
|
638
|
+
<Bloque pos={[0, -0.03, 0.022]} tam={[0.12, 0.12, 0.004]} color={hsl(0.13 * i + 0.4, 0.6, 0.72)} />
|
|
639
|
+
</group>
|
|
640
|
+
);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/** Tienda de videojuegos: pared de juegos, mostrador con caja, tele de demostración, isla de ofertas y neón. */
|
|
644
|
+
const SetTienda: React.FC<{t: number}> = ({t}) => (
|
|
645
|
+
<group>
|
|
646
|
+
<Cuarto ancho={9} fondo={7} alto={3.4} pared="#232A44" lateral="#1D233A" piso="#3A3340" />
|
|
647
|
+
{/* pared de juegos: dos filas de estantes */}
|
|
648
|
+
{[-3.2, -1.55, 0.1].map((x, i) => (
|
|
649
|
+
<React.Fragment key={x}>
|
|
650
|
+
<Estante pos={[x, 0.75, -3.25]} semilla={i * 7} />
|
|
651
|
+
<Estante pos={[x, 2.05, -3.25]} semilla={i * 7 + 3} />
|
|
652
|
+
</React.Fragment>
|
|
653
|
+
))}
|
|
654
|
+
<Emisivo pos={[-1.55, 3.05, -3.36]} tam={[4.9, 0.05, 0.04]} color="#7FE3FF" fuerza={1.4} />
|
|
655
|
+
<pointLight position={[-1.55, 2.9, -2.6]} color="#7FE3FF" intensity={1.2} distance={5} decay={1.6} />
|
|
656
|
+
{/* tele de demostración y letrero de neón (un control) */}
|
|
657
|
+
<Tele3D pos={[2.8, 1.95, -3.32]} ancho={1.3} t={t} fase={2} luz={1.6} />
|
|
658
|
+
<group position={[2.8, 2.95, -3.36]}>
|
|
659
|
+
{[
|
|
660
|
+
[0, 0.16, 0.9, 0],
|
|
661
|
+
[0, -0.16, 0.9, 0],
|
|
662
|
+
[-0.45, 0, 0.32, 90],
|
|
663
|
+
[0.45, 0, 0.32, 90],
|
|
664
|
+
].map(([x, y, l, r], i) => (
|
|
665
|
+
<Emisivo key={i} pos={[x, y, 0]} tam={[l, 0.05, 0.04]} rot={[0, 0, (r * Math.PI) / 180]} color="#FF3B6B" fuerza={2.1 * (Math.sin(t * 11) > -0.9 ? 1 : 0.6)} />
|
|
666
|
+
))}
|
|
667
|
+
<Emisivo pos={[-0.22, 0, 0]} tam={[0.16, 0.05, 0.04]} color="#FFD23D" fuerza={2} />
|
|
668
|
+
<Emisivo pos={[-0.22, 0, 0]} tam={[0.05, 0.16, 0.04]} color="#FFD23D" fuerza={2} />
|
|
669
|
+
<Emisivo pos={[0.2, 0.03, 0]} tam={[0.06, 0.06, 0.04]} color="#3DB8FF" fuerza={2} />
|
|
670
|
+
<Emisivo pos={[0.3, -0.04, 0]} tam={[0.06, 0.06, 0.04]} color="#3DDC84" fuerza={2} />
|
|
671
|
+
<pointLight position={[0, 0, 0.6]} color="#FF3B6B" intensity={1.6} distance={4} />
|
|
672
|
+
</group>
|
|
673
|
+
{/* mostrador con caja registradora y vitrina */}
|
|
674
|
+
<Bloque pos={[2.6, 0.5, -1.2]} tam={[2.2, 1.0, 0.8]} color="#4A3B5C" />
|
|
675
|
+
<Bloque pos={[2.6, 1.02, -1.2]} tam={[2.3, 0.05, 0.9]} color="#E9E1D2" />
|
|
676
|
+
<Emisivo pos={[2.6, 0.55, -0.79]} tam={[1.9, 0.5, 0.02]} color="#9AD7FF" fuerza={0.5} />
|
|
677
|
+
{[0, 1, 2, 3].map((i) => (
|
|
678
|
+
<Bloque key={i} pos={[1.9 + i * 0.46, 0.5, -0.76]} tam={[0.3, 0.12, 0.04]} color={['#EDEDED', '#2B2B33', '#E5484D', '#3D7BFF'][i]} />
|
|
679
|
+
))}
|
|
680
|
+
<Bloque pos={[3.1, 1.2, -1.25]} tam={[0.5, 0.3, 0.45]} color="#2B2B33" />
|
|
681
|
+
<Emisivo pos={[3.1, 1.42, -1.1]} tam={[0.36, 0.2, 0.03]} rot={[-0.5, 0, 0]} color="#3DDC84" fuerza={1.2 + 0.2 * Math.sin(t * 5)} />
|
|
682
|
+
<Bloque pos={[2.0, 1.14, -1.2]} tam={[0.3, 0.18, 0.2]} color="#EDEDED" radio={0.03} />
|
|
683
|
+
{/* isla de ofertas */}
|
|
684
|
+
<Bloque pos={[-2.6, 0.45, 0.6]} tam={[1.5, 0.9, 0.8]} color="#4A3B5C" />
|
|
685
|
+
{[0, 1, 2, 3].map((i) => (
|
|
686
|
+
<CajaJuego key={i} pos={[-3.15 + i * 0.37, 1.07, 0.6]} i={i + 2} rot={[-18, 0, 0]} />
|
|
687
|
+
))}
|
|
688
|
+
<Emisivo pos={[-2.6, 0.5, 1.01]} tam={[0.9, 0.3, 0.02]} color="#FFD23D" fuerza={1.1} />
|
|
689
|
+
<pointLight position={[-2.4, 2.4, 1.4]} color="#FFE2B8" intensity={1.8} distance={4.5} decay={1.5} />
|
|
690
|
+
{/* póster y tapete */}
|
|
691
|
+
<Poster pos={[-4.38, 2.0, -1.2]} tipo="nave" rot={[0, 90, 0]} />
|
|
692
|
+
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0.4, 0.006, -0.6]}>
|
|
693
|
+
<circleGeometry args={[1.5, 28]} />
|
|
694
|
+
<meshStandardMaterial color="#5B4B8A" roughness={0.95} />
|
|
695
|
+
</mesh>
|
|
696
|
+
{/* luces del techo */}
|
|
697
|
+
{[-2.4, 1.2].map((x) => (
|
|
698
|
+
<group key={x} position={[x, 3.3, -1]}>
|
|
699
|
+
<Emisivo pos={[0, 0, 0]} tam={[1.6, 0.04, 0.14]} color="#FFE9C4" fuerza={1.4} />
|
|
700
|
+
<pointLight position={[0, -0.3, 0]} intensity={2.3} distance={7.5} decay={1.4} color="#FFE2B8" />
|
|
701
|
+
</group>
|
|
702
|
+
))}
|
|
703
|
+
<Polvo centro={[0, 1.8, -1.2]} tam={[6, 2.4, 3]} t={t} n={30} />
|
|
704
|
+
</group>
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
/** Calle de noche con lluvia: fachadas con ventanas, un local con vitrina y neón, farola, banca y coche. */
|
|
708
|
+
const SetCalle: React.FC<{t: number}> = ({t}) => (
|
|
709
|
+
<group>
|
|
710
|
+
{/* asfalto, banqueta y línea de la calle */}
|
|
711
|
+
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 1]} receiveShadow>
|
|
712
|
+
<planeGeometry args={[40, 16]} />
|
|
713
|
+
<meshStandardMaterial color="#14161E" roughness={0.35} metalness={0.3} />
|
|
714
|
+
</mesh>
|
|
715
|
+
<Bloque pos={[0, 0.07, -1.3]} tam={[40, 0.14, 3.6]} color="#3B3D48" rugosidad={0.9} />
|
|
716
|
+
{[-9, -6, -3, 0, 3, 6, 9].map((x) => (
|
|
717
|
+
<Bloque key={x} pos={[x, 0.006, 3.2]} tam={[1.4, 0.01, 0.14]} color="#D8D2B8" />
|
|
718
|
+
))}
|
|
719
|
+
{/* charcos que reflejan el neón */}
|
|
720
|
+
{[
|
|
721
|
+
[-1.2, 1.4, 1.1],
|
|
722
|
+
[1.8, 2.4, 0.8],
|
|
723
|
+
[-3.6, 3.6, 0.7],
|
|
724
|
+
].map(([x, z, r], i) => (
|
|
725
|
+
<mesh key={i} rotation={[-Math.PI / 2, 0, 0]} position={[x, 0.004, z]} scale={[1, 0.55, 1]}>
|
|
726
|
+
<circleGeometry args={[r, 20]} />
|
|
727
|
+
<meshStandardMaterial color="#232A4A" roughness={0.05} metalness={0.9} emissive="#FF3B6B" emissiveIntensity={0.05 + 0.03 * Math.sin(t * 3 + i)} />
|
|
728
|
+
</mesh>
|
|
729
|
+
))}
|
|
730
|
+
{/* fachadas */}
|
|
731
|
+
{[
|
|
732
|
+
[-5.6, 7.5, 5, '#1C2033'],
|
|
733
|
+
[0, 5.2, 5.4, '#2A2338'],
|
|
734
|
+
[5.8, 8.6, 5.6, '#1A1E2E'],
|
|
735
|
+
].map(([x, alto, ancho, color], k) => (
|
|
736
|
+
<group key={k}>
|
|
737
|
+
<Bloque pos={[x as number, (alto as number) / 2, -4.4]} tam={[ancho as number, alto as number, 2.6]} color={color as string} rugosidad={0.95} />
|
|
738
|
+
{Array.from({length: 4 * Math.floor(((alto as number) - 2.6) / 1.15)}, (_, i) => {
|
|
739
|
+
const col = i % 4;
|
|
740
|
+
const fila = Math.floor(i / 4);
|
|
741
|
+
const prendida = azarN(k * 31 + i * 7.3) > 0.45;
|
|
742
|
+
const parpadea = k === 0 && i === 5 ? 0.6 + 0.4 * (Math.sin(t * 9) > 0.2 ? 1 : 0.3) : 1;
|
|
743
|
+
return <Emisivo key={i} pos={[(x as number) - (ancho as number) / 2 + 0.85 + col * (((ancho as number) - 1.7) / 3), 3.4 + fila * 1.15, -3.08]} tam={[0.62, 0.72, 0.03]} color={prendida ? (azarN(i * 3.1 + k) > 0.6 ? '#9AD7FF' : '#FFD98A') : '#141826'} fuerza={prendida ? 0.9 * parpadea : 0.05} />;
|
|
744
|
+
})}
|
|
745
|
+
</group>
|
|
746
|
+
))}
|
|
747
|
+
{/* local: vitrina encendida, puerta, toldo y letrero */}
|
|
748
|
+
<Emisivo pos={[-0.6, 1.35, -3.08]} tam={[2.6, 1.7, 0.04]} color="#E0A050" fuerza={0.5} />
|
|
749
|
+
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
|
750
|
+
<CajaJuego key={i} pos={[-1.5 + (i % 4) * 0.6, i < 4 ? 0.95 : 1.6, -3.02]} i={i} />
|
|
751
|
+
))}
|
|
752
|
+
<Bloque pos={[-0.6, 1.28, -3.03]} tam={[2.6, 0.04, 0.12]} color="#3A2F4E" />
|
|
753
|
+
<Bloque pos={[-0.6, 0.38, -3.02]} tam={[2.7, 0.5, 0.1]} color="#3A2F4E" />
|
|
754
|
+
<Bloque pos={[1.6, 1.15, -3.06]} tam={[0.95, 2.1, 0.08]} color="#4A3B5C" />
|
|
755
|
+
<Emisivo pos={[1.6, 1.5, -3.01]} tam={[0.6, 0.9, 0.02]} color="#FFC56B" fuerza={0.6} />
|
|
756
|
+
<Bloque pos={[0.1, 2.45, -2.75]} rot={[18, 0, 0]} tam={[4.4, 0.08, 0.9]} color="#C62E3A" />
|
|
757
|
+
<group position={[0.1, 3.05, -3.04]}>
|
|
758
|
+
{[-1.2, -0.6, 0, 0.6, 1.2].map((x, i) => (
|
|
759
|
+
<Emisivo key={i} pos={[x, 0, 0]} tam={[0.42, 0.34, 0.05]} color={['#FF3B6B', '#FFD23D', '#3DDC84', '#3DB8FF', '#B98BFF'][i]} fuerza={2 * (Math.floor(t * 2.5) % 6 === i ? 0.45 : 1)} />
|
|
760
|
+
))}
|
|
761
|
+
<pointLight position={[0, 0, 1]} color="#FF6B9A" intensity={2.2} distance={6} decay={1.5} />
|
|
762
|
+
</group>
|
|
763
|
+
<pointLight position={[-0.6, 1.4, -2.2]} color="#FFC56B" intensity={2.4} distance={5.5} decay={1.5} />
|
|
764
|
+
{/* farola */}
|
|
765
|
+
<Bloque pos={[3.0, 1.8, -0.1]} tam={[0.1, 3.6, 0.1]} color="#2B2F3A" metal={0.5} />
|
|
766
|
+
<Bloque pos={[2.7, 3.58, -0.1]} tam={[0.7, 0.07, 0.1]} color="#2B2F3A" metal={0.5} />
|
|
767
|
+
<Emisivo pos={[2.45, 3.5, -0.1]} tam={[0.3, 0.08, 0.16]} color="#FFE2A8" fuerza={2.4} />
|
|
768
|
+
<pointLight position={[2.45, 3.2, -0.1]} color="#FFD9A0" intensity={3} distance={8} decay={1.4} castShadow />
|
|
769
|
+
{/* banca, bote e hidrante */}
|
|
770
|
+
<Bloque pos={[-2.9, 0.5, -2.3]} tam={[1.5, 0.08, 0.45]} color="#8A6A48" />
|
|
771
|
+
<Bloque pos={[-2.9, 0.85, -2.5]} tam={[1.5, 0.4, 0.06]} color="#8A6A48" />
|
|
772
|
+
{[-0.6, 0.6].map((l) => (
|
|
773
|
+
<Bloque key={l} pos={[-2.9 + l, 0.3, -2.3]} tam={[0.08, 0.36, 0.4]} color="#2B2F3A" />
|
|
774
|
+
))}
|
|
775
|
+
<mesh position={[-4.3, 0.55, -2.3]}>
|
|
776
|
+
<cylinderGeometry args={[0.24, 0.2, 0.8, 12]} />
|
|
777
|
+
<meshStandardMaterial color="#2F5D4A" roughness={0.8} />
|
|
778
|
+
</mesh>
|
|
779
|
+
<mesh position={[4.2, 0.42, -0.4]}>
|
|
780
|
+
<cylinderGeometry args={[0.13, 0.15, 0.56, 10]} />
|
|
781
|
+
<meshStandardMaterial color="#C62E3A" roughness={0.6} />
|
|
782
|
+
</mesh>
|
|
783
|
+
{/* coche estacionado */}
|
|
784
|
+
<group position={[-3.4, 0, 1.5]}>
|
|
785
|
+
<Bloque pos={[0, 0.5, 0]} tam={[2.5, 0.5, 1.1]} color="#2D6BFF" radio={0.08} />
|
|
786
|
+
<Bloque pos={[-0.1, 0.95, 0]} tam={[1.3, 0.42, 1.0]} color="#1E2A44" radio={0.08} />
|
|
787
|
+
<Emisivo pos={[1.26, 0.52, 0.36]} tam={[0.03, 0.12, 0.2]} color="#FFF2C8" fuerza={0.5} />
|
|
788
|
+
<Emisivo pos={[1.26, 0.52, -0.36]} tam={[0.03, 0.12, 0.2]} color="#FFF2C8" fuerza={0.5} />
|
|
789
|
+
<Emisivo pos={[-1.26, 0.55, 0.36]} tam={[0.03, 0.1, 0.22]} color="#FF3B30" fuerza={0.9} />
|
|
790
|
+
<Emisivo pos={[-1.26, 0.55, -0.36]} tam={[0.03, 0.1, 0.22]} color="#FF3B30" fuerza={0.9} />
|
|
791
|
+
{[
|
|
792
|
+
[-0.8, 0.56],
|
|
793
|
+
[0.8, 0.56],
|
|
794
|
+
[-0.8, -0.56],
|
|
795
|
+
[0.8, -0.56],
|
|
796
|
+
].map(([x, z], i) => (
|
|
797
|
+
<mesh key={i} position={[x, 0.26, z]} rotation={[Math.PI / 2, 0, 0]}>
|
|
798
|
+
<cylinderGeometry args={[0.26, 0.26, 0.16, 14]} />
|
|
799
|
+
<meshStandardMaterial color="#111216" roughness={0.9} />
|
|
800
|
+
</mesh>
|
|
801
|
+
))}
|
|
802
|
+
</group>
|
|
803
|
+
<pointLight position={[-3.2, 2.6, 3.4]} color="#8FA8FF" intensity={1.6} distance={6} decay={1.5} />
|
|
804
|
+
{/* luna y lluvia (entre las fachadas y las cámaras: una gota pegada al lente se ve como una barra) */}
|
|
805
|
+
<mesh position={[-7, 12, -16]}>
|
|
806
|
+
<sphereGeometry args={[1.1, 18, 14]} />
|
|
807
|
+
<meshStandardMaterial color="#F4F1DE" emissive="#F4F1DE" emissiveIntensity={0.9} toneMapped={false} />
|
|
808
|
+
</mesh>
|
|
809
|
+
{Array.from({length: 90}, (_, i) => {
|
|
810
|
+
const y = 6 - ((t * (5.5 + azarN(i * 1.9) * 2.5) + azarN(i * 4.7) * 6) % 6);
|
|
811
|
+
return <Emisivo key={i} pos={[(azarN(i * 2.3) - 0.5) * 12, y, -2.2 + azarN(i * 5.9) * 3.5]} tam={[0.012, 0.22, 0.012]} color="#BFD4FF" fuerza={0.55} />;
|
|
812
|
+
})}
|
|
813
|
+
</group>
|
|
814
|
+
);
|
|
815
|
+
|
|
630
816
|
const AMBIENTES: Record<NombreAmbiente, Def> = {
|
|
631
817
|
'cuarto-gamer': {
|
|
632
818
|
luz: 'noche',
|
|
@@ -686,7 +872,8 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
|
|
|
686
872
|
espalda: {pos: [2.3, 1.9, 1.3], mirar: [-0.2, 1.35, -3.4], fov: 46, a: {pos: [2.15, 1.85, 1.0]}},
|
|
687
873
|
},
|
|
688
874
|
orden: ['general', 'persona', 'maquina', 'espalda', 'pasillo'],
|
|
689
|
-
|
|
875
|
+
// general y espalda dejan a la persona al centro, justo donde caen las capas 2D
|
|
876
|
+
ordenFondo: ['pasillo', 'maquina'],
|
|
690
877
|
anclas: {maquina: [0.6, 1.42, -3.0], persona: [0.6, 1.6, -0.9], neon: [2.6, 2.9, -3.88], pasillo: [-4.4, 1.4, -0.6]},
|
|
691
878
|
persona: {pos: [0.6, 0, -0.95], rot: [0, 180, 0], pose: 'parado'},
|
|
692
879
|
Set: SetArcade,
|
|
@@ -730,6 +917,44 @@ const AMBIENTES: Record<NombreAmbiente, Def> = {
|
|
|
730
917
|
persona: {pos: [-1.6, 0, -0.88], rot: [0, 180, 0], pose: 'sentado'},
|
|
731
918
|
Set: SetOficina,
|
|
732
919
|
},
|
|
920
|
+
tienda: {
|
|
921
|
+
luz: 'noche',
|
|
922
|
+
fondo: '#0C0E1A',
|
|
923
|
+
niebla: [10, 24],
|
|
924
|
+
post: 'cine',
|
|
925
|
+
planos: {
|
|
926
|
+
general: {pos: [3.3, 2.6, 3.7], mirar: [-0.4, 1.3, -2.2], fov: 50, a: {pos: [2.8, 2.4, 3.1]}},
|
|
927
|
+
persona: {pos: [1.7, 1.6, -2.85], mirar: [0.4, 1.4, -1.0], fov: 44, a: {pos: [1.6, 1.58, -2.7]}},
|
|
928
|
+
estantes: {pos: [-1.3, 1.7, -0.3], mirar: [-1.5, 1.5, -3.3], fov: 46, a: {pos: [-1.5, 1.7, -0.6]}},
|
|
929
|
+
mostrador: {pos: [1.0, 1.9, 0.9], mirar: [2.7, 1.15, -1.2], fov: 46, a: {pos: [1.2, 1.85, 0.6]}},
|
|
930
|
+
tele: {pos: [2.1, 1.9, -0.1], mirar: [2.8, 2.15, -3.35], fov: 44, a: {pos: [2.2, 1.9, -0.4]}},
|
|
931
|
+
isla: {pos: [-1.1, 1.9, 2.4], mirar: [-2.6, 0.95, 0.6], fov: 44, a: {pos: [-1.3, 1.85, 2.1]}},
|
|
932
|
+
},
|
|
933
|
+
orden: ['general', 'persona', 'estantes', 'mostrador', 'tele', 'isla'],
|
|
934
|
+
ordenFondo: ['estantes', 'mostrador', 'tele', 'isla'],
|
|
935
|
+
anclas: {estantes: [-1.5, 1.6, -3.2], mostrador: [2.6, 1.2, -1.2], caja: [3.1, 1.4, -1.2], tele: [2.8, 1.95, -3.3], isla: [-2.6, 1.1, 0.6], persona: [0.4, 1.6, -1.0]},
|
|
936
|
+
persona: {pos: [0.4, 0, -1.0], rot: [0, 180, 0], pose: 'parado'},
|
|
937
|
+
Set: SetTienda,
|
|
938
|
+
},
|
|
939
|
+
calle: {
|
|
940
|
+
luz: 'noche',
|
|
941
|
+
fondo: '#0A1024',
|
|
942
|
+
niebla: [14, 38],
|
|
943
|
+
post: 'cine',
|
|
944
|
+
planos: {
|
|
945
|
+
general: {pos: [3.6, 2.3, 4.8], mirar: [-0.2, 1.7, -2.6], fov: 52, a: {pos: [3.0, 2.2, 4.2]}},
|
|
946
|
+
persona: {pos: [2.45, 1.6, -2.6], mirar: [1.3, 1.4, -0.6], fov: 44, a: {pos: [2.35, 1.58, -2.45]}},
|
|
947
|
+
vitrina: {pos: [-1.7, 1.7, 2.3], mirar: [-0.5, 1.85, -3], fov: 50, a: {pos: [-1.6, 1.7, 1.9]}},
|
|
948
|
+
edificios: {pos: [-1.6, 1.3, 3.0], mirar: [-4.6, 4.6, -3], fov: 52, a: {pos: [-1.8, 1.35, 2.7]}},
|
|
949
|
+
coche: {pos: [-0.3, 1.5, 4.7], mirar: [-3.4, 0.8, 1.5], fov: 50, a: {pos: [-0.5, 1.5, 4.4]}},
|
|
950
|
+
farola: {pos: [0.9, 1.1, 1.8], mirar: [2.6, 3.1, -0.4], fov: 52, a: {pos: [1.0, 1.15, 1.5]}},
|
|
951
|
+
},
|
|
952
|
+
orden: ['general', 'persona', 'vitrina', 'edificios', 'coche', 'farola'],
|
|
953
|
+
ordenFondo: ['vitrina', 'edificios', 'coche', 'farola'],
|
|
954
|
+
anclas: {vitrina: [-0.6, 1.4, -3.0], letrero: [0.1, 3.05, -3.0], farola: [2.45, 3.5, -0.1], coche: [-3.4, 0.9, 1.5], edificios: [-5.6, 5, -3.1], persona: [1.3, 1.6, -0.6]},
|
|
955
|
+
persona: {pos: [1.3, 0, -0.6], rot: [0, 180, 0], pose: 'parado'},
|
|
956
|
+
Set: SetCalle,
|
|
957
|
+
},
|
|
733
958
|
};
|
|
734
959
|
|
|
735
960
|
export const NOMBRES_AMBIENTES = Object.keys(AMBIENTES) as NombreAmbiente[];
|
package/remotion/kit/tres.tsx
CHANGED
|
@@ -270,14 +270,14 @@ const Post: React.FC<{post: Post3D; estado: Estado}> = ({post, estado}) => {
|
|
|
270
270
|
const dist = Math.hypot(estado.pos[0] - estado.mirar[0], estado.pos[1] - estado.mirar[1], estado.pos[2] - estado.mirar[2]);
|
|
271
271
|
const rango = typeof o.foco === 'number' ? o.foco : Math.max(1.2, dist * 0.55);
|
|
272
272
|
const efectos: React.ReactElement[] = [];
|
|
273
|
-
if (o.foco) efectos.push(<DepthOfField key="f" target={estado.mirar} worldFocusRange={rango} bokehScale={4} resolutionScale={0.
|
|
273
|
+
if (o.foco) efectos.push(<DepthOfField key="f" target={estado.mirar} worldFocusRange={rango} bokehScale={4} resolutionScale={0.5} />);
|
|
274
274
|
if (o.miniatura) efectos.push(<TiltShift2 key="m" blur={0.12 * o.miniatura} taper={0.6} start={[0.5, 0.0]} end={[0.5, 1.0]} />);
|
|
275
275
|
if (o.brillo) efectos.push(<Bloom key="b" intensity={o.brillo} luminanceThreshold={0.85} luminanceSmoothing={0.2} mipmapBlur />);
|
|
276
276
|
if (o.vinieta) efectos.push(<Vignette key="v" offset={0.3} darkness={o.vinieta} />);
|
|
277
277
|
if (!efectos.length) return null;
|
|
278
278
|
return (
|
|
279
279
|
<>
|
|
280
|
-
<EffectComposer multisampling={
|
|
280
|
+
<EffectComposer multisampling={2}>{efectos}</EffectComposer>
|
|
281
281
|
<RedibujarAlMontar />
|
|
282
282
|
</>
|
|
283
283
|
);
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -413,7 +413,8 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
|
|
|
413
413
|
};
|
|
414
414
|
|
|
415
415
|
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean; sinMarca?: boolean}> = ({d, T, estilos, miniatura, sinMarca}) => {
|
|
416
|
-
const
|
|
416
|
+
const conAmbiente = !!d.mundo?.some((c: any) => c.tipo === 'ambiente');
|
|
417
|
+
const tema = temaDe(d.estilo, d.tema, estilos, conAmbiente);
|
|
417
418
|
const n = d.escenas.length;
|
|
418
419
|
let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
|
|
419
420
|
if (si < 0) si = n - 1;
|
|
@@ -473,6 +474,8 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
473
474
|
<Mundo d={d} si={si} t={t} T={T} tema={tema} />
|
|
474
475
|
</AbsoluteFill>
|
|
475
476
|
) : null}
|
|
477
|
+
{/* un ambiente puede ser claro (oficina): sombra arriba, donde van subtítulos y titular, para que el texto blanco se lea (en el cierre, todo el cuadro) */}
|
|
478
|
+
{conAmbiente ? <AbsoluteFill style={{background: e.tipo === 'cta' ? 'rgba(0,0,0,0.55)' : 'linear-gradient(180deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.38) 30%, rgba(0,0,0,0) 46%)'}} /> : null}
|
|
476
479
|
{enTransicion && prev ? (
|
|
477
480
|
<AbsoluteFill style={estiloPrevio}>
|
|
478
481
|
<EscenaVista e={prev} t={prev.dur + t} tema={tema} semilla={semilla} sinFondo={conMundo} />
|
|
@@ -204,6 +204,7 @@ export const Anotacion: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
204
204
|
border: `${Math.max(4, tam * 0.1)}px solid ${color}`,
|
|
205
205
|
outline: `${Math.max(2, tam * 0.04)}px solid ${color}`,
|
|
206
206
|
outlineOffset: tam * 0.1,
|
|
207
|
+
background: c.fondo,
|
|
207
208
|
borderRadius: tam * 0.2,
|
|
208
209
|
padding: `${tam * 0.12}px ${tam * 0.42}px`,
|
|
209
210
|
fontFamily: T.fuentes.titulo,
|
|
@@ -11,21 +11,57 @@
|
|
|
11
11
|
import type {Marca} from '../tipos';
|
|
12
12
|
import type {Capa} from './capas';
|
|
13
13
|
import type {Tema} from './tema';
|
|
14
|
+
import {ancho} from '../estilo';
|
|
15
|
+
|
|
16
|
+
/** Renglones que ocupa un texto al partirse por palabras en `maxAncho` (como lo hace el navegador). */
|
|
17
|
+
const renglonesDe = (texto: string, fuente: string, tam: number, maxAncho: number, mayus?: boolean) => {
|
|
18
|
+
const palabras = (mayus ? texto.toUpperCase() : texto).split(/\s+/).filter(Boolean);
|
|
19
|
+
let n = 1;
|
|
20
|
+
let linea = '';
|
|
21
|
+
for (const w of palabras) {
|
|
22
|
+
const prueba = linea ? `${linea} ${w}` : w;
|
|
23
|
+
if (linea && ancho(prueba, fuente, tam, 800) > maxAncho) {
|
|
24
|
+
n++;
|
|
25
|
+
linea = w;
|
|
26
|
+
} else linea = prueba;
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
14
30
|
|
|
15
31
|
type P = Record<string, any>;
|
|
16
32
|
|
|
17
33
|
export const PLANTILLAS = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
|
|
18
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Capas de una plantilla. Sobre un ambiente 3D el fondo cambia de toma en toma (ventanal naranja, pared gris), así
|
|
37
|
+
* que los textos sueltos (sin caja ni contorno propio) llevan contorno oscuro y los chicos una sombra más fuerte.
|
|
38
|
+
*/
|
|
19
39
|
export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
|
|
40
|
+
const capas = capasPlantilla(tipo, p, tema, marca);
|
|
41
|
+
if (!tema.enAmbiente) return capas;
|
|
42
|
+
return capas.map((c) => {
|
|
43
|
+
if (c.tipo !== 'texto' || c.fondo || c.contorno != null) return c;
|
|
44
|
+
if (c.fuente === 'texto' || c.fuente === 'mono') return {...c, sombra: c.sombra ?? '0 2px 6px rgba(0,0,0,0.85), 0 0 18px rgba(0,0,0,0.6)'};
|
|
45
|
+
return {...c, contorno: 8, colorContorno: '#0A0A0E'};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function capasPlantilla(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
|
|
20
50
|
const extra: Capa[] = p.capas ?? [];
|
|
21
51
|
switch (tipo) {
|
|
22
52
|
case 'titular': {
|
|
23
53
|
const y = p.y ?? 560;
|
|
24
54
|
const out: Capa[] = [];
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
const tam = p.tam ?? 104;
|
|
56
|
+
const maxAncho = p.numero != null ? 800 : 920;
|
|
57
|
+
// el texto va centrado en `y`: si se parte en 2+ renglones crece hacia arriba y abajo, así que la etiqueta
|
|
58
|
+
// y el subtítulo se separan medio renglón por cada renglón extra (ajuste por palabras con la fuente real)
|
|
59
|
+
const renglones = renglonesDe(String(p.texto ?? '').replace(/\*/g, ''), tema.fuentes.titulo, tam, maxAncho, tema.tituloMayusculas);
|
|
60
|
+
const extraY = (renglones - 1) * tam * 0.525;
|
|
61
|
+
if (p.etiqueta) out.push({tipo: 'etiqueta', texto: p.etiqueta, x: 80, y: y - 92 - extraY, ancla: 'izquierda', entrada: 'aparecer'});
|
|
62
|
+
out.push({tipo: 'texto', texto: p.texto ?? '', x: 80, y, ancla: 'izquierda', alinear: 'left', tam, maxAncho, entrada: 'subir', en: p.en});
|
|
27
63
|
if (p.numero != null) out.push({tipo: 'insignia', texto: String(p.numero), x: 1000, y, ancla: 'derecha', entrada: 'rebote', en: 0.15});
|
|
28
|
-
if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
|
|
64
|
+
if (p.sub) out.push({tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 600, color: tema.colores.suave, x: 80, y: y + 110 + extraY, ancla: 'izquierda', alinear: 'left', entrada: 'aparecer', en: 0.3});
|
|
29
65
|
return [...out, ...extra];
|
|
30
66
|
}
|
|
31
67
|
case 'dato': {
|
|
@@ -122,12 +158,16 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
|
|
|
122
158
|
const out: Capa[] = [{tipo: 'marca', tam: 210, x: 540, y: 640, entrada: 'rebote'}];
|
|
123
159
|
if (p.pide === 'comenta') {
|
|
124
160
|
const palabra = String(p.palabra ?? 'SÍ').toUpperCase();
|
|
161
|
+
// si "Escribe PALABRA" se parte en dos renglones, el texto crece hacia abajo (arriba está el avatar) y
|
|
162
|
+
// lo demás baja con él
|
|
163
|
+
const textoCta = String(p.texto ?? `Escribe *${palabra}*`);
|
|
164
|
+
const bajaCta = (renglonesDe(textoCta.replace(/\*/g, ''), tema.fuentes.titulo, 110, 940, tema.tituloMayusculas) - 1) * 110 * 0.525;
|
|
125
165
|
out.push(
|
|
126
|
-
{tipo: 'texto', texto:
|
|
127
|
-
...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
|
|
166
|
+
{tipo: 'texto', texto: textoCta, x: 540, y: 900 + bajaCta, tam: 110, maxAncho: 940, entrada: 'subir', en: 0.2},
|
|
167
|
+
...(p.sub ? [{tipo: 'texto', texto: p.sub, fuente: 'texto', tam: 44, peso: 700, color: tema.colores.suave, x: 540, y: 1010 + bajaCta * 2, entrada: 'aparecer', en: 0.4, mayusculas: false}] : []),
|
|
128
168
|
// caja de comentario que se escribe sola y se envía
|
|
129
|
-
{tipo: 'terminal', x: 540, y: 1220, ancho: 760, tam: 46, titulo: 'comentario', fondo: caja, colorTexto: letra, en: 0.5, entrada: 'subir', color: tema.colores.acento, lineas: [{texto: palabra, en: 0.9}]},
|
|
130
|
-
{tipo: 'sello', texto: 'ENVIADO', x: 760, y: 1360, en: 2.1, color: tema.colores.acento, tam: 44},
|
|
169
|
+
{tipo: 'terminal', x: 540, y: 1220 + bajaCta * 2, ancho: 760, tam: 46, titulo: 'comentario', fondo: caja, colorTexto: letra, en: 0.5, entrada: 'subir', color: tema.colores.acento, lineas: [{texto: palabra, en: 0.9}]},
|
|
170
|
+
{tipo: 'sello', texto: 'ENVIADO', x: 760, y: 1360 + bajaCta * 2, en: 2.1, color: tema.colores.acento, tam: 44},
|
|
131
171
|
);
|
|
132
172
|
} else {
|
|
133
173
|
out.push(
|
package/remotion/motor/tema.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type Tema = {
|
|
|
31
31
|
};
|
|
32
32
|
fuentes: {titulo: string; texto: string; mono: string};
|
|
33
33
|
tituloMayusculas: boolean;
|
|
34
|
+
/** El video corre sobre una capa `ambiente` (lo pone el motor): los textos de las plantillas llevan contorno. */
|
|
35
|
+
enAmbiente?: boolean;
|
|
34
36
|
subtitulos: {
|
|
35
37
|
/**
|
|
36
38
|
* karaoke: frase arriba, lo dicho en un color y lo pendiente tenue · seleccion: igual, pero solo la palabra clave
|
|
@@ -252,13 +254,28 @@ export const mezclar = <T,>(a: T, b: any): T => {
|
|
|
252
254
|
return out;
|
|
253
255
|
};
|
|
254
256
|
|
|
255
|
-
/**
|
|
256
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Estilos de papel o cielo (texto oscuro) sobre un ambiente 3D de noche: el texto pasa a claro para que se lea.
|
|
259
|
+
* Los ajustes `tema` del video se aplican después, así que siguen mandando.
|
|
260
|
+
*/
|
|
261
|
+
const SOBRE_NOCHE = {
|
|
262
|
+
textura: 'ninguna',
|
|
263
|
+
colores: {texto: '#FFFFFF', suave: 'rgba(255,255,255,0.6)', tarjeta: '#1C1D24', tarjetaTexto: '#FFFFFF', borde: '#FFFFFF'},
|
|
264
|
+
subtitulos: {color: '#FFFFFF', pendiente: 'rgba(255,255,255,0.5)'},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Tema final: estilo base (o uno propio del config, que puede heredar con `base`) + ajustes del video.
|
|
269
|
+
* `oscuro`: el video corre sobre un ambiente de noche (capa `ambiente` en el mundo).
|
|
270
|
+
*/
|
|
271
|
+
export function temaDe(estilo: string | undefined, ajustes: Record<string, any> | undefined, propios: Record<string, any> = {}, oscuro = false): Tema {
|
|
257
272
|
const resolver = (nombre: string, n = 0): Tema => {
|
|
258
273
|
if (ESTILOS[nombre]) return ESTILOS[nombre];
|
|
259
274
|
const p = propios[nombre];
|
|
260
275
|
if (p && n < 5) return mezclar(resolver(p.base ?? 'minimal', n + 1), p);
|
|
261
276
|
return ESTILOS.minimal;
|
|
262
277
|
};
|
|
263
|
-
|
|
278
|
+
const base = resolver(estilo ?? 'minimal');
|
|
279
|
+
const tema = mezclar(oscuro && base.colores.texto !== '#FFFFFF' ? mezclar(base, SOBRE_NOCHE) : base, ajustes ?? {});
|
|
280
|
+
return oscuro ? {...tema, enAmbiente: true} : tema;
|
|
264
281
|
}
|