morfeo 0.1.0 → 0.1.1
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/index.d.ts +4 -0
- package/lib/config.mjs +48 -5
- package/lib/guion.mjs +9 -5
- package/lib/render.mjs +58 -24
- package/package.json +2 -2
- package/remotion/Short.tsx +6 -6
- package/remotion/motor/Video.tsx +7 -5
- package/remotion/motor/capas.tsx +78 -3
- package/remotion/motor/fondo.tsx +2 -3
- package/remotion/motor/plantillas.tsx +3 -1
- package/remotion/motor/tema.ts +9 -4
- package/remotion/motor/widgets.tsx +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morfeo
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **Documentación: [ureckchan.github.io/morfeo](https://ureckchan.github.io/morfeo/)** · Ejemplos: [github.com/UreckChan/morfeo](https://github.com/UreckChan/morfeo)
|
|
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.
|
package/index.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export type Capa = CapaBase &
|
|
|
87
87
|
| {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
|
|
88
88
|
| {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
|
|
89
89
|
| {tipo: 'seguir'; usuario?: string; texto?: string; pulsar?: number}
|
|
90
|
+
| {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
|
|
90
91
|
);
|
|
91
92
|
|
|
92
93
|
export type FondoEscena =
|
|
@@ -182,6 +183,8 @@ export interface Video {
|
|
|
182
183
|
/** Idioma de Kokoro: "es", "en-us", "en-gb", "fr-fr", "it", "pt-br", "ja", "cmn", "hi". */
|
|
183
184
|
lang?: string;
|
|
184
185
|
scenes: Escena[];
|
|
186
|
+
/** Segundo del video para la miniatura (por defecto, cuando la primera escena ya está completa). Sale sin subtítulos. */
|
|
187
|
+
miniaturaEn?: number;
|
|
185
188
|
/** Datos libres que se copian a salida/resumen.json. */
|
|
186
189
|
meta?: Record<string, unknown>;
|
|
187
190
|
}
|
|
@@ -221,6 +224,7 @@ export interface Config {
|
|
|
221
224
|
musicas?: Record<string, Musica>;
|
|
222
225
|
/** Carpeta de los .mp4 (por defecto ./salida). */
|
|
223
226
|
salida?: string;
|
|
227
|
+
/** gl: por defecto 'angle' (GPU) en Mac con Apple Silicon y 'swangle' (CPU) en lo demás; si la GPU falla, reintenta con CPU. */
|
|
224
228
|
render?: {crf?: number; gl?: 'swangle' | 'angle' | 'egl' | 'swiftshader' | 'vulkan'; navegador?: string | null; concurrencia?: number | null; miniatura?: boolean};
|
|
225
229
|
}
|
|
226
230
|
|
package/lib/config.mjs
CHANGED
|
@@ -5,16 +5,37 @@ import {pathToFileURL} from 'node:url';
|
|
|
5
5
|
|
|
6
6
|
export const NOMBRES_CONFIG = ['morfeo.config.mjs', 'morfeo.config.js'];
|
|
7
7
|
|
|
8
|
+
// Voces de Kokoro v1.0 (archivo voices-v1.0.bin)
|
|
9
|
+
export const VOCES_KOKORO = 'af_alloy af_aoede af_bella af_heart af_jessica af_kore af_nicole af_nova af_river af_sarah af_sky am_adam am_echo am_eric am_fenrir am_liam am_michael am_onyx am_puck am_santa bf_alice bf_emma bf_isabella bf_lily bm_daniel bm_fable bm_george bm_lewis ef_dora em_alex em_santa ff_siwis hf_alpha hf_beta hm_omega hm_psi if_sara im_nicola jf_alpha jf_gongitsune jf_nezumi jf_tebukuro jm_kumo pf_dora pm_alex pm_santa zf_xiaobei zf_xiaoni zf_xiaoxiao zf_xiaoyi zm_yunjian zm_yunxi zm_yunxia zm_yunyang'.split(' ');
|
|
10
|
+
|
|
11
|
+
/** Las voces más parecidas a un nombre mal escrito (para sugerir). */
|
|
12
|
+
const parecidas = (nombre, lista, n = 3) => {
|
|
13
|
+
const d = (a, b) => {
|
|
14
|
+
const m = Array.from({length: a.length + 1}, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
15
|
+
for (let j = 1; j <= b.length; j++) m[0][j] = j;
|
|
16
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
17
|
+
return m[a.length][b.length];
|
|
18
|
+
};
|
|
19
|
+
return [...lista].sort((a, b) => d(nombre, a) - d(nombre, b)).slice(0, n);
|
|
20
|
+
};
|
|
21
|
+
|
|
8
22
|
export const TIPOS_CLASICOS = ['title', 'year', 'text', 'icon', 'compare', 'card', 'dialog', 'aliens', 'memory', 'morph', 'cta'];
|
|
9
23
|
export const TIPOS_MOTOR = ['capas', 'titular', 'dato', 'lista', 'telefono', 'comparar', 'eleccion', 'cta'];
|
|
10
24
|
export const TIPOS_ESCENA = [...new Set([...TIPOS_CLASICOS, ...TIPOS_MOTOR])];
|
|
11
25
|
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
|
|
12
|
-
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'];
|
|
26
|
+
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'];
|
|
13
27
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
|
14
28
|
export const MUSICAS = ['lofi', 'lofi2', 'chillpop', 'synth', 'house', 'ballad', 'trap'];
|
|
15
29
|
|
|
16
30
|
export class ErrorMorfeo extends Error {}
|
|
17
31
|
|
|
32
|
+
/**
|
|
33
|
+
* WebGL por defecto: en Mac con Apple Silicon la tarjeta de video (angle) es estable y ~4 veces más rápida;
|
|
34
|
+
* en lo demás, por software (swangle), que funciona en cualquier computadora. Si angle falla, el render
|
|
35
|
+
* reintenta solo con swangle.
|
|
36
|
+
*/
|
|
37
|
+
export const glPorDefecto = () => (process.platform === 'darwin' && process.arch === 'arm64' ? 'angle' : 'swangle');
|
|
38
|
+
|
|
18
39
|
/** Una carpeta de guiones: cada <id>.json o <id>.mjs es un video. */
|
|
19
40
|
async function leerCarpeta(dir) {
|
|
20
41
|
const out = {};
|
|
@@ -84,7 +105,8 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
84
105
|
const errores = [];
|
|
85
106
|
const estilosPropios = cfg.estilos ?? {};
|
|
86
107
|
const estilo = cfg.estilo ?? 'datos-curiosos';
|
|
87
|
-
const
|
|
108
|
+
const assetsDir = path.resolve(proyecto, cfg.assets ?? 'assets');
|
|
109
|
+
const ctx = {proyecto, marca, voces: cfg.voces ?? {}, musicas: cfg.musicas ?? {}, errores, estilo, estilosPropios, assetsDir};
|
|
88
110
|
if (!ESTILOS.includes(estilo) && !estilosPropios[estilo]) errores.push(`estilo "${estilo}" no existe. Opciones: ${[...ESTILOS, ...Object.keys(estilosPropios)].join(', ')}`);
|
|
89
111
|
const componentes = {};
|
|
90
112
|
for (const [nombre, ruta] of Object.entries(cfg.componentes ?? {})) {
|
|
@@ -125,14 +147,27 @@ export async function cargarProyecto(proyecto = process.cwd()) {
|
|
|
125
147
|
componentes,
|
|
126
148
|
render: {
|
|
127
149
|
crf: r.crf ?? 18,
|
|
128
|
-
gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl ||
|
|
150
|
+
gl: process.env.MORFEO_GL || process.env.REMOTION_GL || r.gl || glPorDefecto(),
|
|
129
151
|
navegador: process.env.MORFEO_NAVEGADOR || r.navegador || null,
|
|
130
|
-
concurrencia: r.concurrencia ?? null,
|
|
152
|
+
concurrencia: r.concurrencia ?? null, // null = Morfeo elige según la máquina y el modo (ver render.mjs)
|
|
131
153
|
miniatura: r.miniatura ?? true,
|
|
132
154
|
},
|
|
133
155
|
};
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
const CAMPOS_ARCHIVO = new Set(['src', 'imagen']);
|
|
159
|
+
/** Busca `src`/`imagen` que apunten a assets/ y avisa de los que no existen. */
|
|
160
|
+
function revisarArchivos(x, assetsDir, err, ruta = '') {
|
|
161
|
+
if (Array.isArray(x)) return x.forEach((v, i) => revisarArchivos(v, assetsDir, err, `${ruta}[${i}]`));
|
|
162
|
+
if (!x || typeof x !== 'object') return;
|
|
163
|
+
for (const [k, v] of Object.entries(x)) {
|
|
164
|
+
if (CAMPOS_ARCHIVO.has(k) && typeof v === 'string' && !/^(https?:|data:)/.test(v)) {
|
|
165
|
+
const rel = v.replace(/^\.?\/?(assets\/)?/, '');
|
|
166
|
+
if (!fs.existsSync(path.join(assetsDir, rel))) err(`no existe assets/${rel} (lo pide ${ruta.replace(/^\./, '') || 'una capa'}.${k}). Ponlo en la carpeta assets/ o usa generar: 'descripción' para la IA`);
|
|
167
|
+
} else if (v && typeof v === 'object') revisarArchivos(v, assetsDir, err, `${ruta}.${k}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
136
171
|
function validarCapas(capas, donde, err) {
|
|
137
172
|
if (!Array.isArray(capas)) return err(`${donde}: \`capas\` debe ser una lista`);
|
|
138
173
|
capas.forEach((c, i) => {
|
|
@@ -178,7 +213,13 @@ function normalizarVoz(voz, ctx, err, profundidad = 0) {
|
|
|
178
213
|
if (!/\.npy$/i.test(abs)) err(`la voz ${ref} debe ser un archivo .npy (estilo de voz Kokoro, forma 510×1×256)`);
|
|
179
214
|
else if (!fs.existsSync(abs)) err(`no existe el archivo de voz ${ref}`);
|
|
180
215
|
out.push([abs, peso]);
|
|
181
|
-
} else
|
|
216
|
+
} else {
|
|
217
|
+
if (!VOCES_KOKORO.includes(ref)) {
|
|
218
|
+
const alias = Object.keys(ctx.voces);
|
|
219
|
+
err(`la voz "${ref}" no existe. ¿Quisiste decir ${parecidas(ref, [...alias, ...VOCES_KOKORO]).map((x) => `"${x}"`).join(', ')}? En español: em_alex, ef_dora, em_santa`);
|
|
220
|
+
}
|
|
221
|
+
out.push([ref, peso]);
|
|
222
|
+
}
|
|
182
223
|
}
|
|
183
224
|
return out;
|
|
184
225
|
}
|
|
@@ -237,5 +278,7 @@ function validarVideo(id, v, ctx) {
|
|
|
237
278
|
if (v.voice != null) voice = normalizarVoz(v.voice, ctx, err);
|
|
238
279
|
else if (necesitaVoz) err('falta `voice` (p. ej. "em_alex", una mezcla [["em_alex", 0.6], ["am_michael", 0.4]], un .npy, o `audio` en cada escena)');
|
|
239
280
|
if (v.hud != null) validarCapas(v.hud, 'hud', err);
|
|
281
|
+
// archivos de assets/ que piden las escenas y el HUD: mejor avisar ahora que a mitad del render
|
|
282
|
+
revisarArchivos({scenes, hud: v.hud, tema: v.tema}, ctx.assetsDir, err);
|
|
240
283
|
return {...v, estilo, voice, music: normalizarMusica(v.music, ctx, err), scenes};
|
|
241
284
|
}
|
package/lib/guion.mjs
CHANGED
|
@@ -13,14 +13,18 @@ export const ICONOS = [
|
|
|
13
13
|
];
|
|
14
14
|
const COLORES = {blanco: '#FFFFFF', amarillo: '#FFD400', rojo: '#FF4050', verde: '#5ADC6E', cian: '#50DCFF'};
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/** id corto para el archivo: sin acentos, en minúsculas, hasta ~38 letras y sin cortar palabras. */
|
|
17
|
+
export const slug = (s) => {
|
|
18
|
+
const base = s
|
|
18
19
|
.normalize('NFD')
|
|
19
|
-
.replace(/[
|
|
20
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
20
21
|
.toLowerCase()
|
|
21
22
|
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
-
.replace(/^-+|-+$/g, '')
|
|
23
|
-
|
|
23
|
+
.replace(/^-+|-+$/g, '');
|
|
24
|
+
if (base.length <= 38) return base || 'video';
|
|
25
|
+
const corto = base.slice(0, 39);
|
|
26
|
+
return corto.slice(0, Math.max(corto.lastIndexOf('-'), 12)).replace(/-+$/, '') || 'video';
|
|
27
|
+
};
|
|
24
28
|
|
|
25
29
|
const INSTRUCCIONES = `Eres guionista de videos cortos verticales (YouTube Shorts / TikTok / Reels) de 25 a 45 segundos.
|
|
26
30
|
Escribes en el idioma que se te pida, con frases cortas, naturales y fáciles de decir en voz alta.
|
package/lib/render.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Render: audio (Python) -> datos -> video (Remotion). También lista y Studio.
|
|
2
2
|
import {spawn, spawnSync} from 'node:child_process';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import {createRequire} from 'node:module';
|
|
6
7
|
import path from 'node:path';
|
|
@@ -205,37 +206,70 @@ export async function render({proyecto = process.cwd(), ids, todos = false, log
|
|
|
205
206
|
const chromiumOptions = {gl: p.render.gl};
|
|
206
207
|
const browserExecutable = p.render.navegador ?? (await asegurarNavegador(log));
|
|
207
208
|
const hechos = [];
|
|
209
|
+
// cuadros en paralelo: con GPU aguanta más; por software cada pestaña come mucha CPU y memoria
|
|
210
|
+
const nucleos = os.cpus().length;
|
|
211
|
+
const concurrenciaBase = p.render.concurrencia ?? (p.render.gl === 'angle' ? Math.max(2, Math.min(6, Math.floor(nucleos / 2))) : Math.max(1, Math.min(3, Math.floor(nucleos / 4))));
|
|
212
|
+
const sinTTY = !process.stdout.isTTY;
|
|
208
213
|
for (const [i, id] of lista.entries()) {
|
|
209
214
|
const t = Date.now();
|
|
210
215
|
const inputProps = {videos: {[id]: datos[id]}, marca, ...extras};
|
|
211
|
-
const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions});
|
|
212
|
-
let ultimo = -1;
|
|
213
216
|
const mp4 = salidaDe(p, id);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
217
|
+
let gl = p.render.gl;
|
|
218
|
+
let concurrencia = concurrenciaBase;
|
|
219
|
+
let composition;
|
|
220
|
+
// hasta 3 intentos: si el navegador truena, se baja la concurrencia y, con GPU, se pasa a software
|
|
221
|
+
for (let intento = 1; ; intento++) {
|
|
222
|
+
const chromium = {gl};
|
|
223
|
+
let ultimo = -1;
|
|
224
|
+
try {
|
|
225
|
+
composition = await selectComposition({serveUrl, id, inputProps, browserExecutable, chromiumOptions: chromium});
|
|
226
|
+
await renderMedia({
|
|
227
|
+
serveUrl,
|
|
228
|
+
composition,
|
|
229
|
+
inputProps,
|
|
230
|
+
browserExecutable,
|
|
231
|
+
chromiumOptions: chromium,
|
|
232
|
+
codec: 'h264',
|
|
233
|
+
crf: p.render.crf,
|
|
234
|
+
audioBitrate: '192k',
|
|
235
|
+
imageFormat: 'jpeg',
|
|
236
|
+
jpegQuality: 92,
|
|
237
|
+
concurrency: concurrencia,
|
|
238
|
+
outputLocation: mp4,
|
|
239
|
+
onProgress: ({progress}) => {
|
|
240
|
+
alProgreso?.(id, progress);
|
|
241
|
+
// con terminal: cada 10 % en la misma línea; sin terminal (logs, CI): cada 25 %
|
|
242
|
+
const paso = sinTTY ? 4 : 10;
|
|
243
|
+
const n = Math.floor(progress * paso);
|
|
244
|
+
if (n !== ultimo) {
|
|
245
|
+
if (sinTTY) log(`[${i + 1}/${lista.length}] ${id}: video ${Math.round((n / paso) * 100)}%`);
|
|
246
|
+
else process.stdout.write(`\r[${i + 1}/${lista.length}] ${id}: video ${n * 10}% `);
|
|
247
|
+
}
|
|
248
|
+
ultimo = n;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
chromiumOptions.gl = gl;
|
|
252
|
+
break;
|
|
253
|
+
} catch (e) {
|
|
254
|
+
const msg = String(e?.message ?? e);
|
|
255
|
+
const truena = /crash|Session closed|Target closed|Protocol error|WebGL|context lost|browser has disconnected|Navigation failed/i.test(msg);
|
|
256
|
+
if (!truena || intento >= 3) throw e;
|
|
257
|
+
if (gl === 'angle' && /WebGL|context/i.test(msg)) gl = 'swangle';
|
|
258
|
+
else if (concurrencia > 1) concurrencia = Math.max(1, Math.floor(concurrencia / 2));
|
|
259
|
+
else if (gl === 'angle') gl = 'swangle';
|
|
260
|
+
log(`\n el navegador falló; reintento con ${gl === 'angle' ? 'GPU' : 'CPU'} y ${concurrencia} cuadro(s) a la vez...`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
234
263
|
let jpg = null;
|
|
235
264
|
if (p.render.miniatura) {
|
|
236
|
-
// miniatura:
|
|
265
|
+
// miniatura: cuando la primera escena ya está completa (o en `miniaturaEn`), sin subtítulos
|
|
237
266
|
jpg = path.join(p.salida, `${id}.jpg`);
|
|
238
|
-
|
|
267
|
+
const d = datos[id];
|
|
268
|
+
const e0 = d.escenas[0];
|
|
269
|
+
const seg = p.videos[id].miniaturaEn ?? Math.min(e0.dur - 0.15, Math.max(1.4, e0.habla[1] * 0.85));
|
|
270
|
+
const frame = Math.max(0, Math.min(composition.durationInFrames - 1, Math.round(seg * composition.fps)));
|
|
271
|
+
const props = {...inputProps, miniatura: true};
|
|
272
|
+
await renderStill({serveUrl, composition: {...composition, props: {...composition.props, miniatura: true}}, inputProps: props, browserExecutable, chromiumOptions: {gl}, frame, imageFormat: 'jpeg', jpegQuality: 92, output: jpg});
|
|
239
273
|
}
|
|
240
274
|
const segundos = Math.round((Date.now() - t) / 1000);
|
|
241
275
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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://github.
|
|
8
|
+
"homepage": "https://ureckchan.github.io/morfeo/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/UreckChan/morfeo.git"
|
package/remotion/Short.tsx
CHANGED
|
@@ -30,7 +30,7 @@ import {temaDe} from './motor/tema';
|
|
|
30
30
|
import {VideoConAudio} from './motor/Video';
|
|
31
31
|
import {loadFont} from '@remotion/fonts';
|
|
32
32
|
|
|
33
|
-
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
33
|
+
export type PropsShort = {id: string; videos?: Record<string, Datos>; marca?: TipoMarca; guias?: boolean; miniatura?: boolean; fuentes?: {familia: string; url: string}[]; estilos?: Record<string, any>};
|
|
34
34
|
|
|
35
35
|
const ADELANTO_GANCHO = 0.4; // el gancho arranca ya animado: el fotograma 0 nunca está vacío
|
|
36
36
|
const CIERRE_BUCLE = 0.5; // al final se funde con el fotograma 0 para que el video se repita sin corte
|
|
@@ -220,7 +220,7 @@ const Guias: React.FC = () => (
|
|
|
220
220
|
</AbsoluteFill>
|
|
221
221
|
);
|
|
222
222
|
|
|
223
|
-
export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d, T, guias}) => {
|
|
223
|
+
export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean; miniatura?: boolean}> = ({d, T, guias, miniatura}) => {
|
|
224
224
|
const n = d.escenas.length;
|
|
225
225
|
let si = n - 1;
|
|
226
226
|
for (let i = 0; i < n; i++) if (T < d.escenas[i].t0 + d.escenas[i].dur) {
|
|
@@ -238,7 +238,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
|
|
|
238
238
|
<Fondo T={T} d={d} e={e} t={t} salida={si === n - 1 ? 0 : clamp((t - (e.dur - 0.12)) / 0.12)} />
|
|
239
239
|
<CapaEscena e={e} si={si} t={t} d={d} ultima={si === n - 1} />
|
|
240
240
|
{remate && <Destello t={t} semilla={d.indice} />}
|
|
241
|
-
<Subtitulos T={T} d={d} />
|
|
241
|
+
{miniatura ? null : <Subtitulos T={T} d={d} />}
|
|
242
242
|
</AbsoluteFill>
|
|
243
243
|
{e.tipo !== 'cta' && <Marca />}
|
|
244
244
|
{guias && <Guias />}
|
|
@@ -246,7 +246,7 @@ export const Fotograma: React.FC<{d: Datos; T: number; guias?: boolean}> = ({d,
|
|
|
246
246
|
);
|
|
247
247
|
};
|
|
248
248
|
|
|
249
|
-
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes, estilos}) => {
|
|
249
|
+
export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, miniatura, fuentes, estilos}) => {
|
|
250
250
|
const frame = useCurrentFrame();
|
|
251
251
|
const [listo, setListo] = useState(false);
|
|
252
252
|
const [handle] = useState(() => delayRender('Cargando fuentes'));
|
|
@@ -259,13 +259,13 @@ export const Short: React.FC<PropsShort> = ({id, videos, marca, guias, fuentes,
|
|
|
259
259
|
const d = videos?.[id];
|
|
260
260
|
if (!d) throw new Error(`El video "${id}" no está preparado. Corre: npx morfeo render ${id}`);
|
|
261
261
|
if (!listo) return null;
|
|
262
|
-
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} />;
|
|
262
|
+
if (d.estilo && d.estilo !== 'datos-curiosos') return <VideoConAudio d={d} frame={frame} marca={{...MARCA_VACIA, ...marca}} estilos={estilos} miniatura={miniatura} />;
|
|
263
263
|
const T = frame / FPS;
|
|
264
264
|
const kb = clamp((T - (d.duracion - CIERRE_BUCLE)) / CIERRE_BUCLE);
|
|
265
265
|
return (
|
|
266
266
|
<MarcaContexto.Provider value={{...MARCA_VACIA, ...marca}}>
|
|
267
267
|
<AbsoluteFill>
|
|
268
|
-
<Fotograma d={d} T={T} guias={guias} />
|
|
268
|
+
<Fotograma d={d} T={T} guias={guias} miniatura={miniatura} />
|
|
269
269
|
{kb > 0 && (
|
|
270
270
|
<AbsoluteFill style={{opacity: backOut(kb, 0)}}>
|
|
271
271
|
<Fotograma d={d} T={0} />
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import {Avatar, MarcaContexto, useMarca} from '../piezas';
|
|
|
5
5
|
import {FPS, W, clamp, easeIO, easeOut, lerp} from '../estilo';
|
|
6
6
|
import type {Datos, Escena} from '../tipos';
|
|
7
7
|
import {Capas} from './capas';
|
|
8
|
-
import {FondoTema, Vineta} from './fondo';
|
|
8
|
+
import {FondoTema, Textura, Vineta} from './fondo';
|
|
9
9
|
import {capasDe} from './plantillas';
|
|
10
10
|
import {Subtitulos} from './subtitulos';
|
|
11
11
|
import {mezclar, temaDe, type Tema} from './tema';
|
|
@@ -66,7 +66,7 @@ const MarcaEsquina: React.FC<{tema: Tema}> = ({tema}) => {
|
|
|
66
66
|
);
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any
|
|
69
|
+
export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, T, estilos, miniatura}) => {
|
|
70
70
|
const tema = temaDe(d.estilo, d.tema, estilos);
|
|
71
71
|
const n = d.escenas.length;
|
|
72
72
|
let si = d.escenas.findIndex((e) => T < e.t0 + e.dur);
|
|
@@ -100,19 +100,21 @@ export const VideoMotor: React.FC<{d: Datos; T: number; estilos?: Record<string,
|
|
|
100
100
|
<AbsoluteFill style={estiloActual}>
|
|
101
101
|
<EscenaVista e={e} t={t} tema={tema} semilla={semilla} />
|
|
102
102
|
</AbsoluteFill>
|
|
103
|
+
{/* 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 */}
|
|
104
|
+
{tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
|
|
103
105
|
<Vineta fuerza={tema.vineta ?? 0} />
|
|
104
106
|
{d.hud?.length ? <Hud d={d} T={T} tema={tema} /> : null}
|
|
105
|
-
{e.tipo !== 'cta' ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
|
|
107
|
+
{e.tipo !== 'cta' && !miniatura ? <Subtitulos tema={e.p.tema ? mezclar(tema, e.p.tema) : tema} palabras={e.palabras ?? []} t={t} dur={e.dur} /> : null}
|
|
106
108
|
{e.tipo !== 'cta' ? <MarcaEsquina tema={tema} /> : null}
|
|
107
109
|
</AbsoluteFill>
|
|
108
110
|
);
|
|
109
111
|
};
|
|
110
112
|
|
|
111
113
|
/** Componente raíz de un video del motor de capas (con su audio). */
|
|
112
|
-
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any
|
|
114
|
+
export const VideoConAudio: React.FC<{d: Datos; frame: number; marca: any; estilos?: Record<string, any>; miniatura?: boolean}> = ({d, frame, marca, estilos, miniatura}) => (
|
|
113
115
|
<MarcaContexto.Provider value={marca}>
|
|
114
116
|
<AbsoluteFill>
|
|
115
|
-
<VideoMotor d={d} T={frame / FPS} estilos={estilos} />
|
|
117
|
+
<VideoMotor d={d} T={frame / FPS} estilos={estilos} miniatura={miniatura} />
|
|
116
118
|
<Audio src={staticFile(d.audio)} />
|
|
117
119
|
</AbsoluteFill>
|
|
118
120
|
</MarcaContexto.Provider>
|
package/remotion/motor/capas.tsx
CHANGED
|
@@ -14,6 +14,9 @@ export type Capa = Record<string, any> & {tipo: string};
|
|
|
14
14
|
|
|
15
15
|
const ENTRADA_DUR = 0.5;
|
|
16
16
|
|
|
17
|
+
// el personaje de una serie sigue en pantalla de escena en escena: no "reaparece" en cada una
|
|
18
|
+
const entradaPorDefecto = (c: Capa) => (c.tipo === 'personaje' ? 'ninguna' : 'escala');
|
|
19
|
+
|
|
17
20
|
/** Posición, escala, giro y opacidad de una capa en el instante t (entrada, movimiento, salida). */
|
|
18
21
|
function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
|
|
19
22
|
const t = ctx.t;
|
|
@@ -26,7 +29,7 @@ function animar(c: Capa, ctx: CtxEscena, relativo: boolean) {
|
|
|
26
29
|
let r = c.rotar ?? 0;
|
|
27
30
|
let o = c.opacidad ?? 1;
|
|
28
31
|
if (t < t0) return null; // con entrada 'ninguna' aparece de golpe en su momento
|
|
29
|
-
switch (c.entrada ??
|
|
32
|
+
switch (c.entrada ?? entradaPorDefecto(c)) {
|
|
30
33
|
case 'aparecer':
|
|
31
34
|
o *= easeOut(p);
|
|
32
35
|
break;
|
|
@@ -185,6 +188,8 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
185
188
|
return <Tarjeta c={c} ctx={ctx} />;
|
|
186
189
|
case 'telefono':
|
|
187
190
|
return <Telefono c={c} ctx={ctx} />;
|
|
191
|
+
case 'ventana':
|
|
192
|
+
return <Ventana c={c} ctx={ctx} />;
|
|
188
193
|
case 'forma':
|
|
189
194
|
return <Forma c={c} ctx={ctx} />;
|
|
190
195
|
case 'icono': {
|
|
@@ -260,7 +265,7 @@ const TextoCapa: React.FC<{c: Capa; ctx: CtxEscena; fuente: string}> = ({c, ctx,
|
|
|
260
265
|
const vel = typeof c.escribir === 'number' ? c.escribir : 22; // letras por segundo
|
|
261
266
|
const n = Math.max(0, Math.floor((ctx.t - t0) * vel));
|
|
262
267
|
let cuenta = 0;
|
|
263
|
-
texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : ''));
|
|
268
|
+
texto = texto.replace(/[^*]/g, (ch) => (cuenta++ < n ? ch : ch === '\n' ? '\n' : '')).replace(/\*\*/g, '');
|
|
264
269
|
}
|
|
265
270
|
const color = c.color ?? T.colores.texto;
|
|
266
271
|
const acento = c.acento ?? T.colores.acento;
|
|
@@ -398,7 +403,9 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
398
403
|
const borde = ancho * 0.035;
|
|
399
404
|
return (
|
|
400
405
|
<div style={{width: ancho, height: alto, background: '#0A0A0C', borderRadius: ancho * 0.13, padding: borde, boxShadow: '0 30px 60px rgba(0,0,0,0.45), inset 0 0 0 3px #2A2A30', boxSizing: 'border-box'}}>
|
|
401
|
-
<div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ??
|
|
406
|
+
<div style={{position: 'relative', width: '100%', height: '100%', borderRadius: ancho * 0.1, overflow: 'hidden', background: c.pantalla ?? `linear-gradient(160deg, ${ctx.tema.colores.acento2}, ${ctx.tema.colores.acento} 60%, #111)`}}>
|
|
407
|
+
{/* sin imagen ni capas: una pantalla de app genérica en vez de un rectángulo negro */}
|
|
408
|
+
{!c.src && !c.capas?.length ? <PantallaGenerica ancho={ancho} t={ctx.t} /> : null}
|
|
402
409
|
{c.src ? (
|
|
403
410
|
/\.(mp4|webm|mov)$/i.test(c.src) ? (
|
|
404
411
|
<OffthreadVideo src={recurso(c.src)} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
@@ -418,6 +425,74 @@ const Telefono: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
418
425
|
);
|
|
419
426
|
};
|
|
420
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Ventana circular (escotilla, lupa, mirilla): muestra una imagen o capas adentro, con borde metálico y remaches.
|
|
430
|
+
* {tipo: 'ventana', diametro: 640, src: 'interior.png'} o {tipo: 'ventana', capas: [...]} (coordenadas desde el centro)
|
|
431
|
+
* borde: 'metal' | 'ninguno' | '#color' · remaches · zoom: [inicio, fin] para acercar el contenido · fondo: color detrás
|
|
432
|
+
*/
|
|
433
|
+
const Ventana: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
434
|
+
const d = c.diametro ?? 640;
|
|
435
|
+
const aro = c.borde === 'ninguno' ? 0 : c.grosor ?? Math.round(d * 0.06);
|
|
436
|
+
const metal = c.borde && c.borde !== 'metal' && c.borde !== 'ninguno' ? c.borde : null;
|
|
437
|
+
const t0 = cuando(c.en, ctx, 0);
|
|
438
|
+
const k = clamp((ctx.t - t0) / Math.max(0.5, ctx.dur - t0));
|
|
439
|
+
const zoom = Array.isArray(c.zoom) ? lerp(c.zoom[0], c.zoom[1], easeIO(k)) : c.zoom ?? 1;
|
|
440
|
+
const n = c.remaches === false ? 0 : c.remaches ?? 16;
|
|
441
|
+
const total = d + aro * 2;
|
|
442
|
+
return (
|
|
443
|
+
<div style={{position: 'relative', width: total, height: total}}>
|
|
444
|
+
<div
|
|
445
|
+
style={{
|
|
446
|
+
position: 'absolute',
|
|
447
|
+
inset: 0,
|
|
448
|
+
borderRadius: '50%',
|
|
449
|
+
background: metal ?? 'conic-gradient(from 210deg, #8C96A3, #E3E8EE, #7A8592, #C9D0D8, #6E7884, #DDE3EA, #8C96A3)',
|
|
450
|
+
boxShadow: '0 24px 50px rgba(0,0,0,0.45), inset 0 0 0 3px rgba(0,0,0,0.35)',
|
|
451
|
+
}}
|
|
452
|
+
/>
|
|
453
|
+
<div style={{position: 'absolute', left: aro, top: aro, width: d, height: d, borderRadius: '50%', overflow: 'hidden', background: c.fondo ?? '#0C1A2A', boxShadow: 'inset 0 0 40px rgba(0,0,0,0.6)'}}>
|
|
454
|
+
<div style={{position: 'absolute', inset: 0, transform: `scale(${zoom})`}}>
|
|
455
|
+
{c.src ? <Img src={recurso(c.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} /> : null}
|
|
456
|
+
<div style={{position: 'absolute', left: '50%', top: '50%', width: 0, height: 0}}>
|
|
457
|
+
{(c.capas ?? []).map((h: Capa, i: number) => (
|
|
458
|
+
<CapaVista key={i} c={h} ctx={ctx} relativo i={i} />
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
{/* brillo del vidrio */}
|
|
463
|
+
<div style={{position: 'absolute', inset: 0, borderRadius: '50%', background: 'radial-gradient(circle at 30% 25%, rgba(255,255,255,0.22), transparent 40%)'}} />
|
|
464
|
+
</div>
|
|
465
|
+
{Array.from({length: n}, (_, i) => {
|
|
466
|
+
const a = (i / n) * Math.PI * 2;
|
|
467
|
+
const r = d / 2 + aro / 2;
|
|
468
|
+
return <div key={i} style={{position: 'absolute', left: total / 2 + Math.cos(a) * r - aro * 0.16, top: total / 2 + Math.sin(a) * r - aro * 0.16, width: aro * 0.32, height: aro * 0.32, borderRadius: '50%', background: 'radial-gradient(circle at 35% 35%, #F2F5F8, #6E7884)'}} />;
|
|
469
|
+
})}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/** Pantalla de relleno: barra de app, un video con botón de play y renglones de texto. */
|
|
475
|
+
const PantallaGenerica: React.FC<{ancho: number; t: number}> = ({ancho, t}) => {
|
|
476
|
+
const u = ancho / 100;
|
|
477
|
+
const pulso = 1 + 0.05 * Math.sin(t * 4);
|
|
478
|
+
return (
|
|
479
|
+
<div style={{position: 'absolute', inset: 0, padding: `${12 * u}px ${7 * u}px`, display: 'flex', flexDirection: 'column', gap: 4 * u}}>
|
|
480
|
+
<div style={{display: 'flex', alignItems: 'center', gap: 3 * u}}>
|
|
481
|
+
<div style={{width: 9 * u, height: 9 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.85)'}} />
|
|
482
|
+
<div style={{flex: 1, height: 3 * u, borderRadius: 99, background: 'rgba(255,255,255,0.6)'}} />
|
|
483
|
+
</div>
|
|
484
|
+
<div style={{position: 'relative', height: 60 * u, borderRadius: 5 * u, background: 'rgba(0,0,0,0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
|
|
485
|
+
<div style={{width: 18 * u, height: 18 * u, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center', transform: `scale(${pulso})`}}>
|
|
486
|
+
<div style={{width: 0, height: 0, borderStyle: 'solid', borderWidth: `${4.5 * u}px 0 ${4.5 * u}px ${7.5 * u}px`, borderColor: 'transparent transparent transparent #111', marginLeft: 1.5 * u}} />
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
{[90, 70, 80, 50].map((w, i) => (
|
|
490
|
+
<div key={i} style={{width: `${w}%`, height: 4 * u, borderRadius: 99, background: 'rgba(255,255,255,0.55)'}} />
|
|
491
|
+
))}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
};
|
|
495
|
+
|
|
421
496
|
const Forma: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
422
497
|
const T = ctx.tema;
|
|
423
498
|
const color = c.color ?? T.colores.acento;
|
package/remotion/motor/fondo.tsx
CHANGED
|
@@ -110,8 +110,8 @@ export const Particulas: React.FC<{p: TParticulas; t: number; semilla: string}>
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
/** Ruido fino (película) o papel: un SVG fractal estable. */
|
|
113
|
-
const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
|
|
114
|
-
<AbsoluteFill style={{opacity: tipo === 'papel' ? 0.
|
|
113
|
+
export const Textura: React.FC<{tipo: 'papel' | 'grano'}> = ({tipo}) => (
|
|
114
|
+
<AbsoluteFill style={{opacity: tipo === 'papel' ? 0.22 : 0.1, mixBlendMode: 'multiply', pointerEvents: 'none'}}>
|
|
115
115
|
<svg width={W} height={H}>
|
|
116
116
|
<filter id={`tx-${tipo}`}>
|
|
117
117
|
<feTurbulence type="fractalNoise" baseFrequency={tipo === 'papel' ? 0.9 : 1.6} numOctaves={tipo === 'papel' ? 3 : 2} seed={3} />
|
|
@@ -139,7 +139,6 @@ export const FondoTema: React.FC<{tema: Tema; fondo?: TFondo; t: number; semilla
|
|
|
139
139
|
}}
|
|
140
140
|
/>
|
|
141
141
|
) : null}
|
|
142
|
-
{tema.textura && tema.textura !== 'ninguna' ? <Textura tipo={tema.textura} /> : null}
|
|
143
142
|
{tema.particulas ? <Particulas p={tema.particulas} t={t} semilla={semilla} /> : null}
|
|
144
143
|
</AbsoluteFill>
|
|
145
144
|
);
|
|
@@ -74,7 +74,9 @@ export function capasDe(tipo: string, p: P, tema: Tema, marca: Marca): Capa[] {
|
|
|
74
74
|
case 'eleccion': {
|
|
75
75
|
const ops: P[] = (p.opciones ?? []).map((o: any) => (typeof o === 'string' ? {texto: o} : o));
|
|
76
76
|
const out: Capa[] = [];
|
|
77
|
-
|
|
77
|
+
// con subtítulos al centro (serie), la pregunta y los botones suben para no encimarse
|
|
78
|
+
const subsAlCentro = tema.subtitulos.tipo !== 'ninguno' && tema.subtitulos.y > 850 && tema.subtitulos.y < 1300;
|
|
79
|
+
const yTit = p.y ?? (subsAlCentro && (p.formato ?? 'cartas') === 'botones' ? 420 : 640);
|
|
78
80
|
if (p.pregunta) out.push({tipo: 'texto', texto: p.pregunta, x: 540, y: yTit, tam: p.tamPregunta ?? 84, maxAncho: 900, entrada: 'subir'});
|
|
79
81
|
if ((p.formato ?? 'cartas') === 'botones') {
|
|
80
82
|
ops.forEach((o, i) =>
|
package/remotion/motor/tema.ts
CHANGED
|
@@ -159,10 +159,11 @@ export const ESTILOS: Record<string, Tema> = {
|
|
|
159
159
|
'3d': {
|
|
160
160
|
fondo: {tipo: 'degradado', colores: ['#BFE3FF', '#EAF6FF']},
|
|
161
161
|
vineta: 0.1,
|
|
162
|
-
|
|
162
|
+
// cielo claro: el texto propio va oscuro; los subtítulos siguen blancos con contorno negro
|
|
163
|
+
colores: {texto: '#14233A', acento: '#FF8A00', acento2: '#FF5A36', suave: '#4F6278', tarjeta: 'rgba(20,20,20,0.78)', tarjetaTexto: '#FFFFFF', borde: '#141414'},
|
|
163
164
|
fuentes: {titulo: MONT, texto: MONT, mono: MONO},
|
|
164
165
|
tituloMayusculas: true,
|
|
165
|
-
subtitulos: {tipo: 'palabra', y:
|
|
166
|
+
subtitulos: {tipo: 'palabra', y: 1400, tam: 76, maxPalabras: 1, color: '#FFFFFF', pendiente: '#FFFFFF', resalte: '', contorno: 10},
|
|
166
167
|
transicion: 'corte',
|
|
167
168
|
marca: 'esquina',
|
|
168
169
|
zoomLento: 1.0,
|
|
@@ -182,11 +183,15 @@ export const ESTILOS: Record<string, Tema> = {
|
|
|
182
183
|
};
|
|
183
184
|
|
|
184
185
|
const esObjeto = (x: unknown): x is Record<string, any> => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
185
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* Mezcla profunda: `b` gana sobre `a` (los arreglos se reemplazan completos).
|
|
188
|
+
* Solo `fondo` se reemplaza entero, porque cada tipo de fondo lleva campos distintos; lo demás
|
|
189
|
+
* (subtitulos, colores, particulas...) se combina: {subtitulos: {tipo: 'bloque'}} conserva color y tamaño.
|
|
190
|
+
*/
|
|
186
191
|
export const mezclar = <T,>(a: T, b: any): T => {
|
|
187
192
|
if (!esObjeto(b)) return a;
|
|
188
193
|
const out: any = {...a};
|
|
189
|
-
for (const [k, v] of Object.entries(b)) out[k] = esObjeto(v) && esObjeto(out[k])
|
|
194
|
+
for (const [k, v] of Object.entries(b)) out[k] = k !== 'fondo' && esObjeto(v) && esObjeto(out[k]) ? mezclar(out[k], v) : v;
|
|
190
195
|
return out;
|
|
191
196
|
};
|
|
192
197
|
|
|
@@ -19,18 +19,18 @@ const animado = (c: C, ctx: CtxEscena, campo = 'valor', dur = 0.8) => {
|
|
|
19
19
|
|
|
20
20
|
export const Barra: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
21
21
|
const T = ctx.tema;
|
|
22
|
-
const ancho = c.ancho ??
|
|
22
|
+
const ancho = c.ancho ?? 760;
|
|
23
23
|
const v = clamp(animado(c, ctx) / (c.max ?? 100));
|
|
24
24
|
const color = c.color ?? T.colores.acento;
|
|
25
25
|
return (
|
|
26
26
|
<div style={{width: ancho, background: c.fondo ?? T.colores.tarjeta, borderRadius: 16, padding: '14px 20px', boxShadow: '0 10px 26px rgba(0,0,0,0.25)', border: `3px solid ${c.borde ?? 'rgba(0,0,0,0.08)'}`, fontFamily: T.fuentes.texto}}>
|
|
27
27
|
{c.titulo || c.mostrar !== false ? (
|
|
28
28
|
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10, color: T.colores.tarjetaTexto}}>
|
|
29
|
-
<span style={{fontSize: c.tamTitulo ??
|
|
30
|
-
{c.mostrar !== false ? <span style={{fontSize:
|
|
29
|
+
<span style={{fontSize: c.tamTitulo ?? 34, fontWeight: 800, letterSpacing: 1, textTransform: 'uppercase'}}>{c.titulo ?? ''}</span>
|
|
30
|
+
{c.mostrar !== false ? <span style={{fontSize: 40, fontWeight: 800, color}}>{formatoNumero(v * (c.max ?? 100), c.decimales ?? 0)}{c.unidad ?? '%'}</span> : null}
|
|
31
31
|
</div>
|
|
32
32
|
) : null}
|
|
33
|
-
<div style={{height: c.alto ??
|
|
33
|
+
<div style={{height: c.alto ?? 30, background: c.pista ?? 'rgba(127,127,127,0.25)', borderRadius: 99, overflow: 'hidden'}}>
|
|
34
34
|
<div style={{width: `${v * 100}%`, height: '100%', background: color, borderRadius: 99, boxShadow: `0 0 14px ${color}`}} />
|
|
35
35
|
</div>
|
|
36
36
|
</div>
|