morfeo 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/morfeo.mjs +1 -0
- package/index.d.ts +17 -2
- package/lib/config.mjs +2 -2
- package/lib/estructura.mjs +15 -4
- package/package.json +1 -1
- package/remotion/kit/diorama.tsx +489 -0
- package/remotion/kit/index.ts +1 -0
- package/remotion/kit/tres.tsx +123 -7
- package/remotion/motor/Video.tsx +13 -7
- package/remotion/motor/capas.tsx +2 -0
- package/remotion/motor/fichas.tsx +255 -0
- package/remotion/motor/modelo3d.tsx +1 -1
- package/remotion/motor/subtitulos.tsx +2 -2
- package/remotion/motor/tema.ts +20 -1
package/bin/morfeo.mjs
CHANGED
|
@@ -168,6 +168,7 @@ async function main() {
|
|
|
168
168
|
serie: 'series con personaje y capítulos: HUD (días, barras), cartas VS, "comenta", subtítulos en bloque',
|
|
169
169
|
infografia: 'infografía plana: medidores, barras, globos, pantalla dividida, rayos X',
|
|
170
170
|
'3d': 'escenas 3D con recorrido de cámara (tu .glb), una palabra grande a la vez',
|
|
171
|
+
maqueta: 'diorama 3D low-poly con corte de tierra: una palabra al centro, etiquetas con flecha, cortes rápidos (kit diorama)',
|
|
171
172
|
minimal: 'oscuro y limpio, para lo que sea',
|
|
172
173
|
};
|
|
173
174
|
for (const e of ESTILOS) console.log(` ${e.padEnd(16)} ${desc[e] ?? ''}`);
|
package/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ interface EscenaBase {
|
|
|
21
21
|
audio?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'minimal' | (string & {});
|
|
24
|
+
export type Estilo = 'datos-curiosos' | 'explicativo' | 'documental' | 'ilustrado' | 'historia' | 'mesa' | 'serie' | 'infografia' | '3d' | 'maqueta' | 'minimal' | (string & {});
|
|
25
25
|
|
|
26
26
|
/** Objetos 3D incluidos para capas {tipo: 'modelo', forma}: mejor opción que un emoji. */
|
|
27
27
|
export type Objeto3D = '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';
|
|
@@ -84,6 +84,14 @@ export type Capa = CapaBase &
|
|
|
84
84
|
| {tipo: 'terminal'; lineas: (string | {texto: string; en?: Momento; salida?: boolean; color?: string})[]; titulo?: string; ancho?: number; tam?: number; fondo?: string; velocidad?: number}
|
|
85
85
|
| {tipo: 'nombre'; texto: string; sub?: string; tam?: number; fondo?: string; colorTexto?: string; borde?: string}
|
|
86
86
|
| {tipo: 'accion'; texto: string; tam?: number; color?: string; fondo?: string; colorTexto?: string}
|
|
87
|
+
/** Tarjetas de datos (estilo maqueta): se llenan o palomean solas desde `en`. */
|
|
88
|
+
| {tipo: 'reloj'; de?: number; a?: number; max?: number; segmentos?: [number, number, string][]; etiqueta?: string | false; tam?: number; color?: string; duracion?: number}
|
|
89
|
+
| {tipo: 'cronometro'; de?: number; a?: number; max?: number; unidad?: 'seg' | 'min'; tam?: number; color?: string; duracion?: number}
|
|
90
|
+
| {tipo: 'termometro'; valor: number; de?: number; min?: number; max?: number; unidad?: string; alto?: number; color?: string; decimales?: number; mostrar?: boolean}
|
|
91
|
+
| {tipo: 'recipientes'; n?: number; valor?: number; forma?: 'garrafon' | 'vaso'; total?: string; color?: string; tamPieza?: number; duracion?: number}
|
|
92
|
+
| {tipo: 'ficha'; titulo?: string; foto?: string; campos: [string, string][]; ancho?: number; color?: string; fondoFoto?: string}
|
|
93
|
+
| {tipo: 'checklist'; items: (string | {texto: string; en?: Momento; pendiente?: boolean})[]; titulo?: string; tam?: number; color?: string; cada?: number}
|
|
94
|
+
| {tipo: 'mapa'; ruta?: [number, number][]; distancia?: string; ancho?: number; color?: string; fondo?: string; inclinar?: number; duracion?: number}
|
|
87
95
|
| {tipo: 'cursor'; a: [number, number]; clic?: Momento; duracion?: number; tam?: number; color?: string}
|
|
88
96
|
| {tipo: 'texto'; texto: string; tam?: number; color?: string; acento?: string; fuente?: 'titulo' | 'texto' | 'mono' | (string & {}); peso?: number; mayusculas?: boolean; contorno?: number; colorContorno?: string; fondo?: string; relleno?: string; radio?: number; maxAncho?: number | 'auto'; alinear?: 'center' | 'left' | 'right'; interlineado?: number; escribir?: boolean | number; subrayado?: boolean}
|
|
89
97
|
| {tipo: 'contador'; de?: number; a: number; prefijo?: string; sufijo?: string; decimales?: number; miles?: string; decimal?: string; duracion?: number; curva?: 'suave' | 'lineal' | 'exponencial'; formato?: 'grande' | 'lcd' | 'chip'; tam?: number; color?: string; fondo?: string}
|
|
@@ -144,6 +152,9 @@ interface EscenaMotorBase extends EscenaBase {
|
|
|
144
152
|
rotulo?: string;
|
|
145
153
|
rotuloEn?: Momento;
|
|
146
154
|
rotuloTam?: number;
|
|
155
|
+
/** 'normal' | 'contorno' (blanco con contorno, clave en acento) | 'pastilla' (caja oscura inclinada: ¡CUIDADO!). Por defecto, el del estilo. */
|
|
156
|
+
rotuloEstilo?: 'normal' | 'contorno' | 'pastilla';
|
|
157
|
+
rotuloInclinar?: number;
|
|
147
158
|
/** Palabras clave de la escena para los subtítulos (la que lleva caja o color de acento en cada frase). */
|
|
148
159
|
claves?: string[];
|
|
149
160
|
/** Ajustes del tema solo para esta escena. */
|
|
@@ -171,7 +182,7 @@ export interface Tema {
|
|
|
171
182
|
colores: {texto: string; acento: string; acento2: string; suave: string; tarjeta: string; tarjetaTexto: string; borde: string};
|
|
172
183
|
fuentes: {titulo: string; texto: string; mono: string};
|
|
173
184
|
tituloMayusculas: boolean;
|
|
174
|
-
subtitulos: {tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno'; y: number; tam: number; color: string; pendiente: string; resalte: string; maxPalabras: number; mayusculas?: boolean; contorno?: number; fondo?: string};
|
|
185
|
+
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};
|
|
175
186
|
transicion: Transicion;
|
|
176
187
|
/** Ritmo de las animaciones: duraciones (s) y curva de las entradas. */
|
|
177
188
|
movimiento?: {entrada?: number; salida?: number; transicion?: number; curva?: 'suave' | 'resorte' | 'seca'};
|
|
@@ -182,6 +193,8 @@ export interface Tema {
|
|
|
182
193
|
desenfoqueBordes?: number;
|
|
183
194
|
vinetaColor?: string;
|
|
184
195
|
};
|
|
196
|
+
/** Rótulo de cada escena: estilo 'normal' | 'contorno' | 'pastilla', inclinación, altura (px) y tamaño. */
|
|
197
|
+
rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla'; inclinar?: number; y?: number; tam?: number};
|
|
185
198
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
186
199
|
zoomLento: number;
|
|
187
200
|
}
|
|
@@ -233,6 +246,8 @@ export interface Video {
|
|
|
233
246
|
mundo?: Capa[];
|
|
234
247
|
/** Pastilla fija arriba para series: 'GYM CON CIENCIA · PARTE 5'. */
|
|
235
248
|
serie?: string;
|
|
249
|
+
/** 'largo' = explicador de 90–130 s (lo asume el estilo maqueta): cambia las reglas de `morfeo revisar`. */
|
|
250
|
+
formato?: 'corto' | 'largo';
|
|
236
251
|
/** Ajustes del estilo para este video (colores, fondo, subtítulos...). */
|
|
237
252
|
tema?: Partial<Tema> & Record<string, unknown>;
|
|
238
253
|
/** Título para publicar. */
|
package/lib/config.mjs
CHANGED
|
@@ -22,8 +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 ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'minimal'];
|
|
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', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion'];
|
|
25
|
+
export const ESTILOS = ['datos-curiosos', 'explicativo', 'documental', 'ilustrado', 'historia', 'mesa', 'serie', 'infografia', '3d', 'maqueta', 'minimal'];
|
|
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', 'escenario', 'recurso', 'dato', 'medida', 'trazo', 'contorno', 'fuente', 'seccion', 'insignia', 'sello', 'cursor', 'terminal', 'nombre', 'accion', 'reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa'];
|
|
27
27
|
export const ESCENARIOS = ['bosque', 'cuarto', 'departamento', 'tunel', 'pasillo'];
|
|
28
28
|
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'];
|
|
29
29
|
export const PROVEEDORES_VOZ = ['kokoro', 'elevenlabs', 'openai'];
|
package/lib/estructura.mjs
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// - Series: una pastilla fija arriba ("TITO DESDE CERO · EP. 1", "GYM CON CIENCIA · PARTE 5") y contadores persistentes.
|
|
19
19
|
// - Cierre con una acción concreta, no solo "sígueme": elegir A o B en comentarios, "escribe PASO si entendiste",
|
|
20
20
|
// "mándaselo al que dice que...", cuenta regresiva para la próxima parte.
|
|
21
|
+
//
|
|
22
|
+
// Formato largo "explicador de maqueta" (2026-11, 4 videos de 102–132 s: castor, tinaco, buey, caseta de cobro):
|
|
23
|
+
// - Un solo diorama 3D low-poly con corte de tierra; 24–25 cortes por minuto (uno cada ~2,4 s) entre ángulos del
|
|
24
|
+
// mismo mundo. Una palabra a la vez al centro, rótulos con contorno, etiquetas con flecha pegadas a los objetos.
|
|
25
|
+
// - Gancho "Imagina que eres un castor…" o "¿Cómo se hace una caseta de cobro?"; cierre "¡Felicidades, eres…!" o un
|
|
26
|
+
// VS de "ya lo sabía / no lo sabía". Aquí 90–130 s está bien: el mundo y los cortes sostienen la atención.
|
|
21
27
|
|
|
22
28
|
/** Las reglas, en el orden en que se le explican a la IA y a quien escribe un guion. */
|
|
23
29
|
export const REGLAS = [
|
|
@@ -30,7 +36,7 @@ export const REGLAS = [
|
|
|
30
36
|
{id: 'escalada', regla: 'Que suba la apuesta', como: 'Cada escena más grave, más grande o más rara que la anterior; lo más fuerte cerca del final, no al principio.'},
|
|
31
37
|
{id: 'cierre', regla: 'Cierre con una acción concreta', como: 'Pide algo fácil y específico: elegir A o B en comentarios, escribir una palabra ("escribe PASO"), mandárselo a alguien ("mándaselo al que dice que…") o esperar la parte 2. "Sígueme" solo no basta.'},
|
|
32
38
|
{id: 'bucle', regla: 'Final que conecta con el inicio', como: 'La última frase puede regresar a la primera imagen o dejar una pregunta abierta, para que se vuelva a ver.'},
|
|
33
|
-
{id: 'duracion', regla: 'Entre 30 y 65 segundos', como: 'La mayoría de los de referencia dura ~1 minuto. Más de 75 s solo si es una serie
|
|
39
|
+
{id: 'duracion', regla: 'Entre 30 y 65 segundos (o 90–130 en maqueta)', como: 'La mayoría de los de referencia dura ~1 minuto. Más de 75 s solo si es una serie, una historia o un explicador de maqueta (estilo maqueta: 90–130 s con un corte de cámara cada 2–3 s dentro del mismo mundo).'},
|
|
34
40
|
];
|
|
35
41
|
|
|
36
42
|
/** Las reglas como texto para el prompt de la IA. */
|
|
@@ -38,7 +44,7 @@ export const REGLAS_TEXTO = `Estructura (medida en videos virales; síguela):
|
|
|
38
44
|
${REGLAS.map((r, i) => `${i + 1}. ${r.regla}: ${r.como}`).join('\n')}`;
|
|
39
45
|
|
|
40
46
|
const palabrasDe = (s) => String(s ?? '').trim().split(/\s+/).filter(Boolean);
|
|
41
|
-
const GANCHOS = /^(
|
|
47
|
+
const GANCHOS = /^(¿|¡|imagina|y si|cómo|como se|si tu|si te|si un|qué pasaría|que pasaria|este es|esta es|esto es|nadie|nunca|todos|a \S+ |hay |tu |tú |te |tienes|cuando)/i;
|
|
42
48
|
|
|
43
49
|
/**
|
|
44
50
|
* Revisa la estructura de un video y devuelve consejos (no bloquea el render).
|
|
@@ -54,6 +60,8 @@ export function revisarEstructura(v, d) {
|
|
|
54
60
|
if (!cuerpo.length) return out;
|
|
55
61
|
const primera = cuerpo[0];
|
|
56
62
|
const clasico = (v.estilo ?? 'datos-curiosos') === 'datos-curiosos';
|
|
63
|
+
// formato largo: explicador de maqueta (o `formato: 'largo'` en el video)
|
|
64
|
+
const largo = v.estilo === 'maqueta' || v.formato === 'largo';
|
|
57
65
|
|
|
58
66
|
// 1) gancho
|
|
59
67
|
const dicho = String(primera.say ?? '').trim();
|
|
@@ -67,7 +75,9 @@ export function revisarEstructura(v, d) {
|
|
|
67
75
|
const es = d?.escenas?.filter((e) => e.tipo !== 'cta') ?? [];
|
|
68
76
|
if (es.length) {
|
|
69
77
|
const dur = d.duracion;
|
|
70
|
-
if (dur >
|
|
78
|
+
if (largo && dur > 140) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los explicadores de maqueta de referencia van de 100 a 130 s.`);
|
|
79
|
+
else if (largo && dur < 60) add('duracion', 'idea', `Dura ${Math.round(dur)} s: en maqueta los de referencia duran 100–130 s; hay espacio para otra pregunta y su respuesta.`);
|
|
80
|
+
else if (!largo && dur > 75) add('duracion', 'idea', `Dura ${Math.round(dur)} s: los de referencia rondan 60 s. Si no es una serie o una historia, recorta (o usa estilo maqueta para un explicador largo).`);
|
|
71
81
|
if (dur < 20) add('duracion', 'idea', `Dura ${Math.round(dur)} s: un poco más de desarrollo (una pregunta y su respuesta) suele retener mejor.`);
|
|
72
82
|
const e0 = es[0];
|
|
73
83
|
if (e0 && e0.habla?.[0] > 0.5) add('gancho', 'aviso', `La voz tarda ${e0.habla[0].toFixed(1)} s en empezar: debe arrancar de inmediato.`);
|
|
@@ -77,7 +87,8 @@ export function revisarEstructura(v, d) {
|
|
|
77
87
|
// algo que cambie a media escena: capas con `en`, cámara con rango, toma con movimiento, o un mundo vivo
|
|
78
88
|
const capas = s.capas ?? [];
|
|
79
89
|
const cambia = capas.some((c) => c.en != null && c.en !== 0 && c.en !== 'inicio') || Array.isArray(s.camara?.zoom) || s.toma?.a || capas.some((c) => c.mover || c.cambios);
|
|
80
|
-
if (
|
|
90
|
+
if (largo && voz > 4.5) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s en una sola toma; en maqueta la cámara corta cada 2–3 s. Pártela en dos tomas.`);
|
|
91
|
+
else if (voz > 6 && !cambia && !v.mundo?.length) add('ritmo', 'aviso', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz sin que nada cambie. Pártela en dos o haz que algo entre a media frase.`);
|
|
81
92
|
else if (voz > 9) add('ritmo', 'idea', `Escena ${i + 1}: ${voz.toFixed(1)} s de voz en una sola escena; en los de referencia ninguna idea pasa de ~5 s.`);
|
|
82
93
|
});
|
|
83
94
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morfeo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// Kit diorama: maqueta 3D low-poly flotante con corte de tierra, personajes, tráfico, rayos X y modelos .glb.
|
|
2
|
+
// Va dentro de un <Lienzo3D>. Todo depende de `t` (segundos): nada corre solo.
|
|
3
|
+
//
|
|
4
|
+
// <Maqueta ancho={20} fondo={14} /> isla con estratos, pasto y flores
|
|
5
|
+
// <Modelo src={props.coche.src} pos={[0, 0, 2]} tam={2} animacion t={t} /> un .glb (o un recurso resuelto)
|
|
6
|
+
// <Persona pos={[1, 0, 3]} pose="saludar" t={t} /> trabajador low-poly
|
|
7
|
+
// <Trafico camino={[[-9, 0, 2], [9, 0, 2]]} n={8} t={t} pluma={{d: 9, cerrada: [[2, 6]]}} />
|
|
8
|
+
// <Corte k={abrir}> <Tinaco /> </Corte> rayos X: abre lo que envuelve
|
|
9
|
+
import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
|
10
|
+
import {continueRender, delayRender} from 'remotion';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import {clone as clonarEsqueleto} from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
13
|
+
import {cargarGltf} from '../motor/modelo3d';
|
|
14
|
+
import {recurso} from '../motor/util';
|
|
15
|
+
import {azarN} from './ilustrado';
|
|
16
|
+
import {Piedras} from './tres';
|
|
17
|
+
|
|
18
|
+
type V3 = [number, number, number];
|
|
19
|
+
type Pieza = {pos?: V3; rot?: V3; escala?: number};
|
|
20
|
+
const RAD = Math.PI / 180;
|
|
21
|
+
const grupo = (p: Pieza) => ({position: p.pos ?? ([0, 0, 0] as V3), rotation: (p.rot ?? [0, 0, 0]).map((g) => g * RAD) as V3, scale: p.escala ?? 1});
|
|
22
|
+
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
|
|
23
|
+
const suave = (x: number) => {
|
|
24
|
+
const c = clamp01(x);
|
|
25
|
+
return c * c * (3 - 2 * c);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Maqueta flotante: una isla de `ancho`×`fondo` con estratos de tierra a la vista en los cuatro lados, pasto, flores,
|
|
30
|
+
* matas y piedritas. La superficie queda en y = 0. `hueco` deja un rectángulo sin pasto (para río, calle, alberca).
|
|
31
|
+
*/
|
|
32
|
+
export const Maqueta: React.FC<
|
|
33
|
+
Pieza & {
|
|
34
|
+
ancho?: number;
|
|
35
|
+
fondo?: number;
|
|
36
|
+
capas?: [string, number][];
|
|
37
|
+
pasto?: string;
|
|
38
|
+
flores?: number;
|
|
39
|
+
matas?: number;
|
|
40
|
+
colores?: string[];
|
|
41
|
+
semilla?: number;
|
|
42
|
+
/** Zonas sin flores ni matas: [x, z, ancho, fondo]. */
|
|
43
|
+
libres?: [number, number, number, number][];
|
|
44
|
+
}
|
|
45
|
+
> = ({ancho = 20, fondo = 14, capas = [['#79C24F', 0.4], ['#9A6A45', 1.6], ['#7D553A', 1.4], ['#8C8580', 1.2]], pasto, flores = 60, matas = 140, colores = ['#B57BE8', '#FFD23F', '#FF6B6B', '#FFFFFF'], semilla = 0, libres = [], ...p}) => {
|
|
46
|
+
const libre = (x: number, z: number) => libres.some(([lx, lz, la, lf]) => Math.abs(x - lx) < la / 2 && Math.abs(z - lz) < lf / 2);
|
|
47
|
+
const total = capas.reduce((a, [, g]) => a + g, 0);
|
|
48
|
+
const refMatas = useRef<THREE.InstancedMesh>(null);
|
|
49
|
+
const refFlores = useRef<THREE.InstancedMesh>(null);
|
|
50
|
+
const refTallos = useRef<THREE.InstancedMesh>(null);
|
|
51
|
+
const puntos = (n: number, s: number) =>
|
|
52
|
+
Array.from({length: n}, (_, i) => [(azarN(s + i * 2) - 0.5) * (ancho - 0.6), (azarN(s + i * 2 + 1) - 0.5) * (fondo - 0.6)] as [number, number]).filter(([x, z]) => !libre(x, z));
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
const o = new THREE.Object3D();
|
|
55
|
+
const m = refMatas.current;
|
|
56
|
+
if (m) {
|
|
57
|
+
const lista = puntos(matas, semilla + 11);
|
|
58
|
+
lista.forEach(([x, z], i) => {
|
|
59
|
+
o.position.set(x, 0.12, z);
|
|
60
|
+
o.rotation.set(0, azarN(semilla + i * 13) * 6, 0);
|
|
61
|
+
const k = 0.6 + azarN(semilla + i * 7) * 0.8;
|
|
62
|
+
o.scale.set(k, k * (1 + azarN(semilla + i) * 0.8), k);
|
|
63
|
+
o.updateMatrix();
|
|
64
|
+
m.setMatrixAt(i, o.matrix);
|
|
65
|
+
});
|
|
66
|
+
m.count = lista.length;
|
|
67
|
+
m.instanceMatrix.needsUpdate = true;
|
|
68
|
+
}
|
|
69
|
+
const f = refFlores.current;
|
|
70
|
+
const tl = refTallos.current;
|
|
71
|
+
if (f && tl) {
|
|
72
|
+
const lista = puntos(flores, semilla + 97);
|
|
73
|
+
const c = new THREE.Color();
|
|
74
|
+
lista.forEach(([x, z], i) => {
|
|
75
|
+
const alto = 0.25 + azarN(semilla + i * 5) * 0.25;
|
|
76
|
+
o.position.set(x, alto / 2, z);
|
|
77
|
+
o.rotation.set(0, 0, 0);
|
|
78
|
+
o.scale.set(1, alto / 0.4, 1);
|
|
79
|
+
o.updateMatrix();
|
|
80
|
+
tl.setMatrixAt(i, o.matrix);
|
|
81
|
+
o.position.set(x, alto + 0.05, z);
|
|
82
|
+
o.scale.setScalar(0.8 + azarN(semilla + i * 3) * 0.6);
|
|
83
|
+
o.updateMatrix();
|
|
84
|
+
f.setMatrixAt(i, o.matrix);
|
|
85
|
+
f.setColorAt(i, c.set(colores[i % colores.length]));
|
|
86
|
+
});
|
|
87
|
+
f.count = lista.length;
|
|
88
|
+
tl.count = lista.length;
|
|
89
|
+
f.instanceMatrix.needsUpdate = true;
|
|
90
|
+
tl.instanceMatrix.needsUpdate = true;
|
|
91
|
+
if (f.instanceColor) f.instanceColor.needsUpdate = true;
|
|
92
|
+
}
|
|
93
|
+
}, [ancho, fondo, matas, flores, semilla, JSON.stringify(libres), colores.join()]);
|
|
94
|
+
let y = 0;
|
|
95
|
+
return (
|
|
96
|
+
<group {...grupo(p)}>
|
|
97
|
+
{capas.map(([color, grosor], i) => {
|
|
98
|
+
const centro = y - grosor / 2;
|
|
99
|
+
y -= grosor;
|
|
100
|
+
return (
|
|
101
|
+
<mesh key={i} position={[0, centro, 0]} receiveShadow castShadow={i === 0}>
|
|
102
|
+
<boxGeometry args={[ancho, grosor, fondo]} />
|
|
103
|
+
<meshStandardMaterial color={i === 0 && pasto ? pasto : color} roughness={0.95} />
|
|
104
|
+
</mesh>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
{/* piedritas en las cuatro caras */}
|
|
108
|
+
<Piedras z={fondo / 2 + 0.01} ancho={ancho} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(ancho * 2.4)} semilla={semilla + 3} />
|
|
109
|
+
<group rotation={[0, Math.PI / 2, 0]}>
|
|
110
|
+
<Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 41} />
|
|
111
|
+
</group>
|
|
112
|
+
<group rotation={[0, -Math.PI / 2, 0]}>
|
|
113
|
+
<Piedras z={ancho / 2 + 0.01} ancho={fondo} y0={-capas[0][1] - 0.2} y1={-total + 0.3} n={Math.round(fondo * 2.4)} semilla={semilla + 77} />
|
|
114
|
+
</group>
|
|
115
|
+
<instancedMesh ref={refMatas} args={[undefined as any, undefined as any, matas]} castShadow frustumCulled={false}>
|
|
116
|
+
<coneGeometry args={[0.07, 0.32, 4]} />
|
|
117
|
+
<meshStandardMaterial color={pasto ?? '#5FAE3C'} roughness={0.9} flatShading />
|
|
118
|
+
</instancedMesh>
|
|
119
|
+
<instancedMesh ref={refTallos} args={[undefined as any, undefined as any, flores]} frustumCulled={false}>
|
|
120
|
+
<cylinderGeometry args={[0.018, 0.018, 0.4, 4]} />
|
|
121
|
+
<meshStandardMaterial color="#3E8A35" roughness={0.9} />
|
|
122
|
+
</instancedMesh>
|
|
123
|
+
<instancedMesh ref={refFlores} args={[undefined as any, undefined as any, flores]} castShadow frustumCulled={false}>
|
|
124
|
+
<icosahedronGeometry args={[0.09, 0]} />
|
|
125
|
+
<meshStandardMaterial roughness={0.7} flatShading />
|
|
126
|
+
</instancedMesh>
|
|
127
|
+
</group>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Un modelo .glb dentro del lienzo: tuyo (`src: 'castor.glb'` en assets/) o un recurso de la biblioteca
|
|
133
|
+
* (`src: props.coche`, lo que deja `{tipo: 'recurso', buscar: 'coche'}` en las props del componente).
|
|
134
|
+
* Se escala para que su lado mayor mida `tam` y se apoya en y = 0. `animacion` reproduce los clips del archivo
|
|
135
|
+
* sincronizados con `t` (true = todos, o el nombre de uno).
|
|
136
|
+
*/
|
|
137
|
+
export const Modelo: React.FC<Pieza & {src: string | {src: string}; tam?: number; t?: number; animacion?: boolean | string; velocidad?: number; color?: string; sombras?: boolean}> = ({src, tam = 1, t = 0, animacion, velocidad = 1, color, sombras = true, ...p}) => {
|
|
138
|
+
const ruta = typeof src === 'string' ? src : src.src;
|
|
139
|
+
const url = recurso(ruta);
|
|
140
|
+
const [g, setG] = useState<{obj: THREE.Object3D; mixer: THREE.AnimationMixer | null} | null>(null);
|
|
141
|
+
const [handle] = useState(() => delayRender(`Cargando modelo ${ruta}`));
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
cargarGltf(url)
|
|
144
|
+
.then((gl) => {
|
|
145
|
+
const copia = clonarEsqueleto(gl.scene) as THREE.Object3D;
|
|
146
|
+
const caja = new THREE.Box3().setFromObject(copia);
|
|
147
|
+
const t3 = caja.getSize(new THREE.Vector3());
|
|
148
|
+
const k = tam / Math.max(t3.x, t3.y, t3.z, 0.0001);
|
|
149
|
+
const centro = caja.getCenter(new THREE.Vector3());
|
|
150
|
+
const envoltura = new THREE.Group();
|
|
151
|
+
copia.position.set(-centro.x, -caja.min.y, -centro.z);
|
|
152
|
+
envoltura.add(copia);
|
|
153
|
+
envoltura.scale.setScalar(k);
|
|
154
|
+
copia.traverse((o: any) => {
|
|
155
|
+
if (o.isMesh) {
|
|
156
|
+
o.castShadow = sombras;
|
|
157
|
+
o.receiveShadow = sombras;
|
|
158
|
+
if (color) {
|
|
159
|
+
o.material = o.material.clone();
|
|
160
|
+
o.material.color = new THREE.Color(color);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
let mixer: THREE.AnimationMixer | null = null;
|
|
165
|
+
if (animacion && gl.animations.length) {
|
|
166
|
+
mixer = new THREE.AnimationMixer(copia);
|
|
167
|
+
const clips = animacion === true ? gl.animations : gl.animations.filter((a) => a.name === animacion || a.name.toLowerCase().includes(String(animacion).toLowerCase()));
|
|
168
|
+
for (const clip of clips.length ? clips : gl.animations.slice(0, 1)) mixer.clipAction(clip).play();
|
|
169
|
+
}
|
|
170
|
+
setG({obj: envoltura, mixer});
|
|
171
|
+
continueRender(handle);
|
|
172
|
+
})
|
|
173
|
+
.catch((e) => {
|
|
174
|
+
continueRender(handle);
|
|
175
|
+
throw new Error(`No pude cargar el modelo ${ruta}: ${e.message}`);
|
|
176
|
+
});
|
|
177
|
+
}, [url, handle, tam, color, String(animacion)]);
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
g?.mixer?.setTime(Math.max(0, t) * velocidad);
|
|
180
|
+
});
|
|
181
|
+
return <group {...grupo(p)}>{g ? <primitive object={g.obj} /> : null}</group>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const MAT = (color: string, r = 0.8) => <meshStandardMaterial color={color} roughness={r} flatShading />;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Persona low-poly (trabajador con casco y chaleco por defecto). Mide ~1.6 de alto con `escala` 1.
|
|
188
|
+
* `pose`: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'celebrar'. `t` mueve brazos y piernas.
|
|
189
|
+
*/
|
|
190
|
+
export const Persona: React.FC<Pieza & {t?: number; pose?: 'parado' | 'saludar' | 'caminar' | 'senalar' | 'señalar' | 'celebrar'; piel?: string; ropa?: string; chaleco?: string | false; casco?: string | false; pantalon?: string}> = ({
|
|
191
|
+
t = 0,
|
|
192
|
+
pose = 'parado',
|
|
193
|
+
piel = '#F2C19B',
|
|
194
|
+
ropa = '#3A6EC8',
|
|
195
|
+
chaleco = '#FF7A1A',
|
|
196
|
+
casco = '#FFC928',
|
|
197
|
+
pantalon = '#2E3A59',
|
|
198
|
+
...p
|
|
199
|
+
}) => {
|
|
200
|
+
const paso = pose === 'caminar' ? Math.sin(t * 9) * 0.6 : 0;
|
|
201
|
+
const respira = Math.sin(t * 2.2) * 0.015;
|
|
202
|
+
let brazoI = 0.15;
|
|
203
|
+
let brazoD = -0.15;
|
|
204
|
+
if (pose === 'saludar') brazoD = -2.6 + Math.sin(t * 10) * 0.35;
|
|
205
|
+
if (pose === 'celebrar') {
|
|
206
|
+
brazoI = 2.7 + Math.sin(t * 9) * 0.2;
|
|
207
|
+
brazoD = -2.7 - Math.sin(t * 9) * 0.2;
|
|
208
|
+
}
|
|
209
|
+
if (pose === 'senalar' || pose === 'señalar') brazoD = -1.55;
|
|
210
|
+
if (pose === 'caminar') {
|
|
211
|
+
brazoI = -paso * 0.8;
|
|
212
|
+
brazoD = paso * 0.8;
|
|
213
|
+
}
|
|
214
|
+
const salto = pose === 'celebrar' ? Math.abs(Math.sin(t * 9)) * 0.12 : pose === 'caminar' ? Math.abs(Math.sin(t * 9)) * 0.04 : 0;
|
|
215
|
+
const brazo = (lado: 1 | -1, ang: number, adelante = 0) => (
|
|
216
|
+
<group position={[0.3 * lado, 1.18, 0]} rotation={[pose === 'caminar' ? ang : adelante, 0, pose === 'caminar' ? 0 : ang]}>
|
|
217
|
+
<mesh position={[0, -0.26, 0]} castShadow>
|
|
218
|
+
<boxGeometry args={[0.14, 0.52, 0.14]} />
|
|
219
|
+
{MAT(chaleco ? ropa : ropa)}
|
|
220
|
+
</mesh>
|
|
221
|
+
<mesh position={[0, -0.56, 0]} castShadow>
|
|
222
|
+
<sphereGeometry args={[0.085, 8, 6]} />
|
|
223
|
+
{MAT(piel)}
|
|
224
|
+
</mesh>
|
|
225
|
+
</group>
|
|
226
|
+
);
|
|
227
|
+
return (
|
|
228
|
+
<group {...grupo(p)}>
|
|
229
|
+
<group position={[0, salto + respira, 0]}>
|
|
230
|
+
{/* piernas */}
|
|
231
|
+
{[-1, 1].map((l) => (
|
|
232
|
+
<group key={l} position={[0.12 * l, 0.62, 0]} rotation={[l * paso, 0, 0]}>
|
|
233
|
+
<mesh position={[0, -0.3, 0]} castShadow>
|
|
234
|
+
<boxGeometry args={[0.17, 0.6, 0.18]} />
|
|
235
|
+
{MAT(pantalon)}
|
|
236
|
+
</mesh>
|
|
237
|
+
<mesh position={[0, -0.6, 0.05]} castShadow>
|
|
238
|
+
<boxGeometry args={[0.19, 0.08, 0.28]} />
|
|
239
|
+
{MAT('#3B2A20')}
|
|
240
|
+
</mesh>
|
|
241
|
+
</group>
|
|
242
|
+
))}
|
|
243
|
+
{/* torso y chaleco */}
|
|
244
|
+
<mesh position={[0, 0.95, 0]} castShadow>
|
|
245
|
+
<boxGeometry args={[0.48, 0.62, 0.28]} />
|
|
246
|
+
{MAT(ropa)}
|
|
247
|
+
</mesh>
|
|
248
|
+
{chaleco ? (
|
|
249
|
+
<mesh position={[0, 0.93, 0]} castShadow>
|
|
250
|
+
<boxGeometry args={[0.5, 0.5, 0.3]} />
|
|
251
|
+
{MAT(chaleco)}
|
|
252
|
+
</mesh>
|
|
253
|
+
) : null}
|
|
254
|
+
{chaleco ? (
|
|
255
|
+
<mesh position={[0, 0.88, 0.152]}>
|
|
256
|
+
<boxGeometry args={[0.5, 0.06, 0.01]} />
|
|
257
|
+
<meshStandardMaterial color="#E8F2F5" roughness={0.3} emissive="#ffffff" emissiveIntensity={0.15} />
|
|
258
|
+
</mesh>
|
|
259
|
+
) : null}
|
|
260
|
+
{brazo(-1, brazoI)}
|
|
261
|
+
{brazo(1, brazoD, pose === 'senalar' || pose === 'señalar' ? -1.4 : 0)}
|
|
262
|
+
{/* cabeza */}
|
|
263
|
+
<mesh position={[0, 1.42, 0]} castShadow>
|
|
264
|
+
<sphereGeometry args={[0.22, 12, 10]} />
|
|
265
|
+
{MAT(piel, 0.7)}
|
|
266
|
+
</mesh>
|
|
267
|
+
{[-1, 1].map((l) => (
|
|
268
|
+
<mesh key={l} position={[0.08 * l, 1.45, 0.2]}>
|
|
269
|
+
<sphereGeometry args={[0.028, 6, 6]} />
|
|
270
|
+
<meshStandardMaterial color="#1B1B1F" />
|
|
271
|
+
</mesh>
|
|
272
|
+
))}
|
|
273
|
+
{casco ? (
|
|
274
|
+
<group position={[0, 1.55, 0]}>
|
|
275
|
+
<mesh castShadow>
|
|
276
|
+
<sphereGeometry args={[0.24, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
|
277
|
+
{MAT(casco, 0.5)}
|
|
278
|
+
</mesh>
|
|
279
|
+
<mesh position={[0, 0, 0.06]}>
|
|
280
|
+
<cylinderGeometry args={[0.3, 0.3, 0.035, 14]} />
|
|
281
|
+
{MAT(casco, 0.5)}
|
|
282
|
+
</mesh>
|
|
283
|
+
</group>
|
|
284
|
+
) : null}
|
|
285
|
+
</group>
|
|
286
|
+
</group>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Coche low-poly (2 de largo). Para tráfico sin descargar nada. */
|
|
291
|
+
export const Coche: React.FC<Pieza & {color?: string; techo?: string; t?: number; rueda?: number}> = ({color = '#E8533B', techo = '#1F3550', t = 0, ...p}) => (
|
|
292
|
+
<group {...grupo(p)}>
|
|
293
|
+
<mesh position={[0, 0.38, 0]} castShadow receiveShadow>
|
|
294
|
+
<boxGeometry args={[2, 0.45, 0.95]} />
|
|
295
|
+
{MAT(color, 0.5)}
|
|
296
|
+
</mesh>
|
|
297
|
+
<mesh position={[-0.1, 0.78, 0]} castShadow>
|
|
298
|
+
<boxGeometry args={[1.1, 0.38, 0.86]} />
|
|
299
|
+
{MAT(techo, 0.4)}
|
|
300
|
+
</mesh>
|
|
301
|
+
{[
|
|
302
|
+
[0.65, 0.48],
|
|
303
|
+
[-0.65, 0.48],
|
|
304
|
+
[0.65, -0.48],
|
|
305
|
+
[-0.65, -0.48],
|
|
306
|
+
].map(([x, z], i) => (
|
|
307
|
+
<mesh key={i} position={[x, 0.18, z]} rotation={[Math.PI / 2, t * 6, 0]} castShadow>
|
|
308
|
+
<cylinderGeometry args={[0.19, 0.19, 0.14, 10]} />
|
|
309
|
+
{MAT('#1E1E22', 0.9)}
|
|
310
|
+
</mesh>
|
|
311
|
+
))}
|
|
312
|
+
{[0.2, -0.2].map((z, i) => (
|
|
313
|
+
<mesh key={i} position={[1.005, 0.42, z * 1.6]}>
|
|
314
|
+
<boxGeometry args={[0.02, 0.1, 0.16]} />
|
|
315
|
+
<meshStandardMaterial color="#FFF6C8" emissive="#FFF2B0" emissiveIntensity={0.6} />
|
|
316
|
+
</mesh>
|
|
317
|
+
))}
|
|
318
|
+
</group>
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const COLORES_COCHE = ['#E8533B', '#2D7BFF', '#2BB673', '#F5B841', '#8E5BD9', '#FF7EB6', '#F2F2F2', '#33363D'];
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Tráfico: `n` coches (o lo que dibuje `pieza`) que avanzan por `camino` a `velocidad` (u/s) con `separacion`.
|
|
325
|
+
* `pluma`: una barrera a `d` unidades del inicio del camino; mientras está cerrada (intervalos [desde, hasta] en
|
|
326
|
+
* segundos) los coches que no la han cruzado se detienen y hacen fila. Todo se calcula de `t`: es determinista.
|
|
327
|
+
*/
|
|
328
|
+
export const Trafico: React.FC<{
|
|
329
|
+
camino: V3[];
|
|
330
|
+
t: number;
|
|
331
|
+
n?: number;
|
|
332
|
+
velocidad?: number;
|
|
333
|
+
separacion?: number;
|
|
334
|
+
desfase?: number;
|
|
335
|
+
pluma?: {d: number; cerrada: [number, number][]};
|
|
336
|
+
colores?: string[];
|
|
337
|
+
pieza?: (i: number, color: string, t: number) => React.ReactNode;
|
|
338
|
+
semilla?: number;
|
|
339
|
+
}> = ({camino, t, n = 8, velocidad = 2.2, separacion = 2.8, desfase = 0, pluma, colores = COLORES_COCHE, pieza, semilla = 0}) => {
|
|
340
|
+
const curva = useMemo(() => new THREE.CatmullRomCurve3(camino.map((p) => new THREE.Vector3(...p)), false, 'catmullrom', 0.02), [JSON.stringify(camino)]);
|
|
341
|
+
const L = curva.getLength();
|
|
342
|
+
// posición libre de cada coche (sin pluma), sin dar la vuelta: el coche 0 va adelante
|
|
343
|
+
const libre = (i: number, tt: number) => velocidad * tt + desfase - i * separacion;
|
|
344
|
+
const filas = (pluma?.cerrada ?? []).map(() => 0); // cuántos coches hacen fila en cada cierre
|
|
345
|
+
const pos: number[] = [];
|
|
346
|
+
for (let i = 0; i < n; i++) {
|
|
347
|
+
let retraso = 0; // segundos que este coche lleva detenido
|
|
348
|
+
if (pluma)
|
|
349
|
+
pluma.cerrada.forEach(([c0, c1], k) => {
|
|
350
|
+
const enCierre = libre(i, c0) - retraso * velocidad;
|
|
351
|
+
// la siguiente pluma por delante (la pluma está en d, d + L, d + 2L... porque el camino se repite)
|
|
352
|
+
const pl = pluma.d + Math.ceil((enCierre - pluma.d) / L) * L;
|
|
353
|
+
const lugar = pl - filas[k] * separacion;
|
|
354
|
+
const llega = c0 + Math.max(0, (lugar - enCierre) / velocidad);
|
|
355
|
+
if (llega >= c1) return; // llega cuando ya abrió
|
|
356
|
+
filas[k]++;
|
|
357
|
+
if (t > llega) retraso += Math.min(t, c1) - llega;
|
|
358
|
+
});
|
|
359
|
+
let s = libre(i, t) - retraso * velocidad;
|
|
360
|
+
if (pos.length) s = Math.min(s, pos[pos.length - 1] - separacion);
|
|
361
|
+
pos.push(s);
|
|
362
|
+
}
|
|
363
|
+
return (
|
|
364
|
+
<group>
|
|
365
|
+
{pos.map((s, i) => {
|
|
366
|
+
const u = ((s % L) + L) % L / L;
|
|
367
|
+
const p = curva.getPointAt(u);
|
|
368
|
+
const d = curva.getTangentAt(u);
|
|
369
|
+
const ang = Math.atan2(-d.z, d.x);
|
|
370
|
+
const color = colores[(i + semilla) % colores.length];
|
|
371
|
+
// aparecen y desaparecen encogiéndose en los extremos del camino (no saltan de un lado a otro)
|
|
372
|
+
const borde = suave(Math.min(u * L, (1 - u) * L) / (separacion * 0.45));
|
|
373
|
+
if (borde <= 0.01) return null;
|
|
374
|
+
return (
|
|
375
|
+
<group key={i} position={p} rotation={[0, ang, 0]} scale={borde}>
|
|
376
|
+
{pieza ? pieza(i, color, t) : <Coche color={color} t={t} />}
|
|
377
|
+
</group>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</group>
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Pluma (barrera) de caseta: `abierta` 0..1 la levanta. El brazo mide `largo` y apunta a +x. */
|
|
385
|
+
export const Pluma: React.FC<Pieza & {abierta?: number; largo?: number}> = ({abierta = 0, largo = 3.2, ...p}) => (
|
|
386
|
+
<group {...grupo(p)}>
|
|
387
|
+
<mesh position={[0, 0.5, 0]} castShadow>
|
|
388
|
+
<boxGeometry args={[0.4, 1, 0.4]} />
|
|
389
|
+
{MAT('#FFC928', 0.5)}
|
|
390
|
+
</mesh>
|
|
391
|
+
<group position={[0, 0.85, 0]} rotation={[0, 0, (suave(abierta) * 80 * Math.PI) / 180]}>
|
|
392
|
+
{Array.from({length: 8}, (_, i) => (
|
|
393
|
+
<mesh key={i} position={[(i + 0.5) * (largo / 8), 0, 0]} castShadow>
|
|
394
|
+
<boxGeometry args={[largo / 8, 0.12, 0.12]} />
|
|
395
|
+
{MAT(i % 2 ? '#FFFFFF' : '#E5484D', 0.5)}
|
|
396
|
+
</mesh>
|
|
397
|
+
))}
|
|
398
|
+
</group>
|
|
399
|
+
</group>
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rayos X: recorta lo que envuelve con un plano para ver adentro. `k` (0..1) abre el corte (anímalo con el tiempo).
|
|
404
|
+
* `eje`: hacia dónde se abre ('z' quita la mitad de enfrente). Lo que va adentro (agua, flotador) ponlo fuera de <Corte>.
|
|
405
|
+
*/
|
|
406
|
+
export const Corte: React.FC<{k: number; eje?: 'x' | 'y' | 'z'; centro?: number; tam?: number; children: React.ReactNode}> = ({k, eje = 'z', centro = 0, tam = 2, children}) => {
|
|
407
|
+
const ref = useRef<THREE.Group>(null);
|
|
408
|
+
const plano = useMemo(() => new THREE.Plane(), []);
|
|
409
|
+
useLayoutEffect(() => {
|
|
410
|
+
const n = eje === 'x' ? new THREE.Vector3(-1, 0, 0) : eje === 'y' ? new THREE.Vector3(0, -1, 0) : new THREE.Vector3(0, 0, -1);
|
|
411
|
+
// k = 0: el plano queda fuera (no recorta); k = 1: corta por `centro`
|
|
412
|
+
const d = centro + (1 - suave(k)) * tam;
|
|
413
|
+
const mundo = new THREE.Vector3();
|
|
414
|
+
ref.current?.getWorldPosition(mundo);
|
|
415
|
+
const off = eje === 'x' ? mundo.x : eje === 'y' ? mundo.y : mundo.z;
|
|
416
|
+
plano.set(n, d + off);
|
|
417
|
+
ref.current?.traverse((o: any) => {
|
|
418
|
+
if (!o.isMesh) return;
|
|
419
|
+
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
|
420
|
+
for (const m of mats) {
|
|
421
|
+
m.clippingPlanes = k > 0 ? [plano] : null;
|
|
422
|
+
m.side = THREE.DoubleSide;
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
return <group ref={ref}>{children}</group>;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/** Gotas de agua que caen de `de` a `a` (por un tubo transparente o de una llave). */
|
|
430
|
+
export const Gotas: React.FC<{de: V3; a: V3; t: number; n?: number; velocidad?: number; color?: string; tam?: number}> = ({de, a, t, n = 6, velocidad = 1.4, color = '#4FB3FF', tam = 0.09}) => (
|
|
431
|
+
<group>
|
|
432
|
+
{Array.from({length: n}, (_, i) => {
|
|
433
|
+
const k = (t * velocidad + i / n) % 1;
|
|
434
|
+
return (
|
|
435
|
+
<mesh key={i} position={[de[0] + (a[0] - de[0]) * k, de[1] + (a[1] - de[1]) * k, de[2] + (a[2] - de[2]) * k]} scale={[1, 1.4, 1]}>
|
|
436
|
+
<sphereGeometry args={[tam, 8, 6]} />
|
|
437
|
+
<meshStandardMaterial color={color} roughness={0.15} emissive={color} emissiveIntensity={0.35} transparent opacity={0.9} />
|
|
438
|
+
</mesh>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</group>
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
/** Confeti 3D que estalla en `pos` cuando `t` pasa de `en`. */
|
|
445
|
+
export const Confeti3D: React.FC<{pos: V3; t: number; en: number; n?: number; colores?: string[]}> = ({pos, t, en, n = 60, colores = ['#FFC400', '#FF5A8A', '#4FB3FF', '#5ADC6E', '#FFFFFF']}) => {
|
|
446
|
+
const dt = t - en;
|
|
447
|
+
if (dt < 0 || dt > 3) return null;
|
|
448
|
+
return (
|
|
449
|
+
<group position={pos}>
|
|
450
|
+
{Array.from({length: n}, (_, i) => {
|
|
451
|
+
const ang = azarN(i) * Math.PI * 2;
|
|
452
|
+
const v = 2 + azarN(i * 3) * 3;
|
|
453
|
+
const vy = 4 + azarN(i * 5) * 4;
|
|
454
|
+
const x = Math.cos(ang) * v * dt;
|
|
455
|
+
const z = Math.sin(ang) * v * dt;
|
|
456
|
+
const y = vy * dt - 4.9 * dt * dt * 0.6;
|
|
457
|
+
return (
|
|
458
|
+
<mesh key={i} position={[x, y, z]} rotation={[dt * 8 + i, dt * 6, i]}>
|
|
459
|
+
<boxGeometry args={[0.12, 0.02, 0.07]} />
|
|
460
|
+
<meshStandardMaterial color={colores[i % colores.length]} roughness={0.5} />
|
|
461
|
+
</mesh>
|
|
462
|
+
);
|
|
463
|
+
})}
|
|
464
|
+
</group>
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/** Linterna: un foco con su cono visible, de `pos` hacia `mirar` (escenas de noche). */
|
|
469
|
+
export const Linterna: React.FC<{pos: V3; mirar: V3; color?: string; fuerza?: number; angulo?: number}> = ({pos, mirar, color = '#FFF1C2', fuerza = 6, angulo = 0.38}) => {
|
|
470
|
+
const objetivo = useMemo(() => new THREE.Object3D(), []);
|
|
471
|
+
useLayoutEffect(() => {
|
|
472
|
+
objetivo.position.set(...mirar);
|
|
473
|
+
objetivo.updateMatrixWorld();
|
|
474
|
+
});
|
|
475
|
+
const dir = new THREE.Vector3(...mirar).sub(new THREE.Vector3(...pos));
|
|
476
|
+
const largo = dir.length();
|
|
477
|
+
const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), dir.clone().normalize());
|
|
478
|
+
const medio = new THREE.Vector3(...pos).add(dir.clone().multiplyScalar(0.5));
|
|
479
|
+
return (
|
|
480
|
+
<group>
|
|
481
|
+
<primitive object={objetivo} />
|
|
482
|
+
<spotLight position={pos} target={objetivo} angle={angulo} penumbra={0.6} decay={0} intensity={fuerza} color={color} castShadow />
|
|
483
|
+
<mesh position={medio} quaternion={q}>
|
|
484
|
+
<coneGeometry args={[Math.tan(angulo) * largo, largo, 32, 1, true]} />
|
|
485
|
+
<meshBasicMaterial color={color} transparent opacity={0.09} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
|
|
486
|
+
</mesh>
|
|
487
|
+
</group>
|
|
488
|
+
);
|
|
489
|
+
};
|
package/remotion/kit/index.ts
CHANGED
package/remotion/kit/tres.tsx
CHANGED
|
@@ -26,7 +26,20 @@ const mezcla = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1]
|
|
|
26
26
|
* escena; sin `a` se acerca un poco sola (`deriva`, 0.06 = 6 % del camino hacia lo que mira). `corte` salta sin viaje.
|
|
27
27
|
*/
|
|
28
28
|
export type Toma3D = {pos: V3; mirar?: V3; fov?: number; a?: {pos?: V3; mirar?: V3; fov?: number}; viaje?: number; corte?: boolean; deriva?: number};
|
|
29
|
-
type Estado = {pos: V3; mirar: V3; fov: number};
|
|
29
|
+
export type Estado = {pos: V3; mirar: V3; fov: number};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dónde cae un punto 3D en la pantalla (1080×1920) con la cámara de este cuadro. Devuelve {x, y, visible}:
|
|
33
|
+
* `visible` es falso si el punto queda detrás de la cámara. Sirve para pegar etiquetas 2D a objetos 3D.
|
|
34
|
+
*/
|
|
35
|
+
export function proyectar(estado: Estado, punto: V3, ancho = 1080, alto = 1920): {x: number; y: number; visible: boolean} {
|
|
36
|
+
const cam = new THREE.PerspectiveCamera(estado.fov, ancho / alto, 0.1, 400);
|
|
37
|
+
cam.position.set(...estado.pos);
|
|
38
|
+
cam.lookAt(...estado.mirar);
|
|
39
|
+
cam.updateMatrixWorld(true);
|
|
40
|
+
const v = new THREE.Vector3(...punto).project(cam);
|
|
41
|
+
return {x: ((v.x + 1) / 2) * ancho, y: ((1 - v.y) / 2) * alto, visible: v.z < 1 && v.z > -1};
|
|
42
|
+
}
|
|
30
43
|
|
|
31
44
|
/** Cámara del cuadro actual. `tomas[i]` es la de la escena i+1; si falta, se conserva la anterior. */
|
|
32
45
|
export function camara3D(tomas: (Toma3D | undefined | null)[], escena: number, tEscena: number, inicios?: number[]): Estado {
|
|
@@ -85,10 +98,104 @@ const Entorno: React.FC<{fuerza: number}> = ({fuerza}) => {
|
|
|
85
98
|
return null;
|
|
86
99
|
};
|
|
87
100
|
|
|
101
|
+
/** Luz del cielo según la hora (0–24): mediodía claro, atardecer naranja, noche azul con luna. */
|
|
102
|
+
type Cielo = {fondo: string; sol: string; fuerzaSol: number; arriba: string; abajo: string; fuerzaHemi: number; entorno: number; altura: number};
|
|
103
|
+
const CIELOS: [number, Cielo][] = [
|
|
104
|
+
[0, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
|
|
105
|
+
[6, {fondo: '#F6C7A0', sol: '#FFB27A', fuerzaSol: 1.4, arriba: '#FFE1C2', abajo: '#9C8A70', fuerzaHemi: 0.8, entorno: 0.4, altura: 0.25}],
|
|
106
|
+
[9, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
|
|
107
|
+
[16, {fondo: '#BFE3FF', sol: '#FFFFFF', fuerzaSol: 2.3, arriba: '#FFFFFF', abajo: '#B9A98A', fuerzaHemi: 1.05, entorno: 0.55, altura: 1}],
|
|
108
|
+
[18.5, {fondo: '#F4A26B', sol: '#FF8A3D', fuerzaSol: 1.8, arriba: '#FFD2A8', abajo: '#8A6A55', fuerzaHemi: 0.75, entorno: 0.38, altura: 0.22}],
|
|
109
|
+
[20.5, {fondo: '#2A2F55', sol: '#8FA4FF', fuerzaSol: 0.6, arriba: '#6474B8', abajo: '#20233A', fuerzaHemi: 0.55, entorno: 0.2, altura: 0.6}],
|
|
110
|
+
[24, {fondo: '#121A33', sol: '#9DB4FF', fuerzaSol: 0.55, arriba: '#5A6FA8', abajo: '#1A1F2E', fuerzaHemi: 0.5, entorno: 0.18, altura: 0.6}],
|
|
111
|
+
];
|
|
112
|
+
const HORAS: Record<string, number> = {amanecer: 6.5, manana: 10, mañana: 10, dia: 12, día: 12, tarde: 16, atardecer: 18.5, noche: 22};
|
|
113
|
+
const colorMezcla = (a: string, b: string, k: number) => '#' + new THREE.Color(a).lerp(new THREE.Color(b), k).getHexString();
|
|
114
|
+
/** Cielo y luces para una hora (número 0–24 o 'dia', 'atardecer', 'noche'...). */
|
|
115
|
+
export function cieloDeHora(hora: number | string): Cielo {
|
|
116
|
+
const h = (((typeof hora === 'string' ? HORAS[hora] ?? 12 : hora) % 24) + 24) % 24;
|
|
117
|
+
let i = CIELOS.findIndex(([x]) => x > h);
|
|
118
|
+
if (i <= 0) i = CIELOS.length - 1;
|
|
119
|
+
const [h0, a] = CIELOS[i - 1];
|
|
120
|
+
const [h1, b] = CIELOS[i];
|
|
121
|
+
const k = suave((h - h0) / (h1 - h0));
|
|
122
|
+
const n = (x: number, y: number) => x + (y - x) * k;
|
|
123
|
+
return {fondo: colorMezcla(a.fondo, b.fondo, k), sol: colorMezcla(a.sol, b.sol, k), fuerzaSol: n(a.fuerzaSol, b.fuerzaSol), arriba: colorMezcla(a.arriba, b.arriba, k), abajo: colorMezcla(a.abajo, b.abajo, k), fuerzaHemi: n(a.fuerzaHemi, b.fuerzaHemi), entorno: n(a.entorno, b.entorno), altura: n(a.altura, b.altura)};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Activa el recorte por planos (para `Corte`, la vista de rayos X). */
|
|
127
|
+
const Recorte: React.FC = () => {
|
|
128
|
+
const {gl} = useThree();
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
gl.localClippingEnabled = true;
|
|
131
|
+
}, [gl]);
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Etiqueta pegada a un punto 3D: pastilla de color con una flecha que apunta al objeto. Sigue al objeto cuando la
|
|
137
|
+
* cámara se mueve. `escena` (1, 2...) la limita a esa escena; `en`/`hasta` son segundos desde que empieza.
|
|
138
|
+
*/
|
|
139
|
+
export type Etiqueta3D = {
|
|
140
|
+
pos: V3;
|
|
141
|
+
texto: string;
|
|
142
|
+
escena?: number | number[];
|
|
143
|
+
en?: number;
|
|
144
|
+
hasta?: number;
|
|
145
|
+
color?: string;
|
|
146
|
+
colorTexto?: string;
|
|
147
|
+
/** Hacia dónde sale la pastilla desde el objeto. */
|
|
148
|
+
lado?: 'arriba' | 'abajo' | 'izq' | 'der';
|
|
149
|
+
/** Largo de la flecha (px). */
|
|
150
|
+
largo?: number;
|
|
151
|
+
tam?: number;
|
|
152
|
+
inclinar?: number;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const EtiquetasPantalla: React.FC<{etiquetas: Etiqueta3D[]; estado: Estado; escena: number; tEscena: number}> = ({etiquetas, estado, escena, tEscena}) => (
|
|
156
|
+
<>
|
|
157
|
+
{etiquetas.map((e, i) => {
|
|
158
|
+
const escenas = e.escena == null ? null : ([] as number[]).concat(e.escena);
|
|
159
|
+
if (escenas && !escenas.includes(escena)) return null;
|
|
160
|
+
const t = tEscena - (e.en ?? 0.2);
|
|
161
|
+
if (t < 0 || (e.hasta != null && tEscena > e.hasta)) return null;
|
|
162
|
+
const p = proyectar(estado, e.pos);
|
|
163
|
+
if (!p.visible) return null;
|
|
164
|
+
const lado = e.lado ?? 'arriba';
|
|
165
|
+
const largo = e.largo ?? 110;
|
|
166
|
+
const tam = e.tam ?? 40;
|
|
167
|
+
const k = Math.min(1, t / 0.35);
|
|
168
|
+
const pop = 1 - Math.exp(-7 * k) * Math.cos(k * 7.5);
|
|
169
|
+
const flecha = largo * Math.min(1, t / 0.25);
|
|
170
|
+
const [dx, dy] = lado === 'arriba' ? [0, -1] : lado === 'abajo' ? [0, 1] : lado === 'izq' ? [-1, 0] : [1, 0];
|
|
171
|
+
const fx = p.x + dx * flecha;
|
|
172
|
+
const fy = p.y + dy * flecha;
|
|
173
|
+
const color = e.color ?? '#2D7BFF';
|
|
174
|
+
const ang = (Math.atan2(-dy, -dx) * 180) / Math.PI;
|
|
175
|
+
const transform = lado === 'arriba' ? 'translate(-50%, -100%)' : lado === 'abajo' ? 'translate(-50%, 0)' : lado === 'izq' ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
|
|
176
|
+
return (
|
|
177
|
+
<React.Fragment key={i}>
|
|
178
|
+
<svg width={1080} height={1920} style={{position: 'absolute', left: 0, top: 0, pointerEvents: 'none'}}>
|
|
179
|
+
<line x1={fx} y1={fy} x2={p.x - dx * 6} y2={p.y - dy * 6} stroke={color} strokeWidth={9} strokeLinecap="round" />
|
|
180
|
+
<g transform={`translate(${p.x} ${p.y}) rotate(${ang})`}>
|
|
181
|
+
<path d="M 0 0 L -30 -17 L -30 17 Z" fill={color} />
|
|
182
|
+
</g>
|
|
183
|
+
</svg>
|
|
184
|
+
<div style={{position: 'absolute', left: fx, top: fy, transform: `${transform} scale(${pop}) rotate(${e.inclinar ?? -3}deg)`, transformOrigin: 'center', background: color, color: e.colorTexto ?? '#FFFFFF', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: tam, padding: `${tam * 0.22}px ${tam * 0.5}px`, borderRadius: tam * 0.3, border: '4px solid rgba(255,255,255,0.95)', boxShadow: '0 8px 18px rgba(0,0,0,0.3)', whiteSpace: 'nowrap', letterSpacing: 0.5}}>
|
|
185
|
+
{e.texto}
|
|
186
|
+
</div>
|
|
187
|
+
</React.Fragment>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
|
|
88
193
|
/**
|
|
89
194
|
* El lienzo del mundo 3D (1080×1920). `luz`:
|
|
90
195
|
* 'dia' cielo claro, sol con sombras suaves (maquetas, cortes de terreno, low-poly)
|
|
91
196
|
* 'calida' cuarto oscuro con un foco cálido desde arriba y su cono visible (mesa, dinero, objetos)
|
|
197
|
+
* `hora` (0–24 o 'atardecer', 'noche'...) cambia el cielo y el sol de la luz 'dia'; anímala para pasar de día a noche.
|
|
198
|
+
* `etiquetas`: pastillas con flecha pegadas a puntos 3D (siguen al objeto con la cámara).
|
|
92
199
|
*/
|
|
93
200
|
export const Lienzo3D: React.FC<{
|
|
94
201
|
tomas: (Toma3D | undefined | null)[];
|
|
@@ -96,25 +203,32 @@ export const Lienzo3D: React.FC<{
|
|
|
96
203
|
tEscena?: number;
|
|
97
204
|
inicios?: number[];
|
|
98
205
|
luz?: 'dia' | 'calida';
|
|
206
|
+
hora?: number | string;
|
|
99
207
|
fondo?: string;
|
|
100
208
|
niebla?: [number, number];
|
|
101
209
|
sol?: V3;
|
|
102
210
|
foco?: V3;
|
|
211
|
+
etiquetas?: Etiqueta3D[];
|
|
212
|
+
/** Encima del 3D, en coordenadas de pantalla (texto, tarjetas). */
|
|
213
|
+
encima?: React.ReactNode;
|
|
103
214
|
children: React.ReactNode;
|
|
104
|
-
}> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], children}) => {
|
|
215
|
+
}> = ({tomas, escena = 1, tEscena = 0, inicios, luz = 'dia', hora, fondo, niebla, sol = [9, 15, 8], foco = [0, 10, 1.5], etiquetas, encima, children}) => {
|
|
105
216
|
const estado = camara3D(tomas, escena, tEscena, inicios);
|
|
106
|
-
const
|
|
217
|
+
const cielo = cieloDeHora(hora ?? 12);
|
|
218
|
+
const color = fondo ?? (luz === 'dia' ? cielo.fondo : '#120A06');
|
|
219
|
+
const posSol: V3 = [sol[0], sol[1] * cielo.altura + 1.5, sol[2]];
|
|
107
220
|
return (
|
|
108
|
-
<div style={{width: 1080, height: 1920}}>
|
|
221
|
+
<div style={{position: 'relative', width: 1080, height: 1920}}>
|
|
109
222
|
<ThreeCanvas width={1080} height={1920} shadows gl={{antialias: true}} camera={{fov: estado.fov, position: estado.pos, near: 0.1, far: 400}}>
|
|
110
223
|
<color attach="background" args={[color]} />
|
|
111
224
|
{niebla ? <fog attach="fog" args={[color, niebla[0], niebla[1]]} /> : null}
|
|
112
225
|
<Camara estado={estado} />
|
|
226
|
+
<Recorte />
|
|
113
227
|
{luz === 'dia' ? (
|
|
114
228
|
<>
|
|
115
|
-
<Entorno fuerza={
|
|
116
|
-
<hemisphereLight args={[
|
|
117
|
-
<directionalLight position={sol} intensity={
|
|
229
|
+
<Entorno fuerza={cielo.entorno} />
|
|
230
|
+
<hemisphereLight args={[cielo.arriba, cielo.abajo, cielo.fuerzaHemi]} />
|
|
231
|
+
<directionalLight position={posSol} color={cielo.sol} intensity={cielo.fuerzaSol} castShadow shadow-mapSize={[2048, 2048]} shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} shadow-camera-far={90} shadow-bias={-0.0006} />
|
|
118
232
|
</>
|
|
119
233
|
) : (
|
|
120
234
|
<>
|
|
@@ -130,6 +244,8 @@ export const Lienzo3D: React.FC<{
|
|
|
130
244
|
)}
|
|
131
245
|
{children}
|
|
132
246
|
</ThreeCanvas>
|
|
247
|
+
{etiquetas?.length ? <EtiquetasPantalla etiquetas={etiquetas} estado={estado} escena={escena} tEscena={tEscena} /> : null}
|
|
248
|
+
{encima}
|
|
133
249
|
</div>
|
|
134
250
|
);
|
|
135
251
|
};
|
package/remotion/motor/Video.tsx
CHANGED
|
@@ -129,26 +129,32 @@ const Rotulo: React.FC<{e: Escena; t: number; tema: Tema}> = ({e, t, tema}) => {
|
|
|
129
129
|
const limpio = texto.replace(/\*/g, '');
|
|
130
130
|
const fuente = tema.fuentes.titulo;
|
|
131
131
|
// cabe en 940 px: se achica si hace falta (el ancho se mide con la fuente ya cargada)
|
|
132
|
-
let tam = e.p.rotuloTam ?? 104;
|
|
132
|
+
let tam = e.p.rotuloTam ?? tema.rotulo?.tam ?? 104;
|
|
133
133
|
try {
|
|
134
134
|
const w = ancho(may ? limpio.toUpperCase() : limpio, fuente, tam, 900);
|
|
135
135
|
if (w > 940) tam = Math.max(56, Math.floor((tam * 940) / w));
|
|
136
136
|
} catch {}
|
|
137
137
|
const claro = tema.colores.texto !== '#FFFFFF';
|
|
138
|
-
const
|
|
138
|
+
const estilo = e.p.rotuloEstilo ?? tema.rotulo?.estilo ?? 'normal';
|
|
139
|
+
const contorno = estilo === 'contorno' || (estilo === 'normal' && !claro && (S.contorno ?? 0) > 0);
|
|
140
|
+
const grosor = estilo === 'contorno' ? Math.max(8, Math.round(tam * 0.11)) : S.contorno;
|
|
141
|
+
const pastilla = estilo === 'pastilla';
|
|
142
|
+
const inclinar = e.p.rotuloInclinar ?? tema.rotulo?.inclinar ?? (pastilla ? -3 : 0);
|
|
143
|
+
const y = tema.rotulo?.y ?? (abajo ? 1470 : 330);
|
|
139
144
|
return (
|
|
140
|
-
<div style={{position: 'absolute', left: 50, width: W - 100, top:
|
|
145
|
+
<div style={{position: 'absolute', left: 50, width: W - 100, top: y, transform: `translate(0, -50%) scale(${0.86 + 0.14 * resorte(k, 0.3)}) rotate(${inclinar}deg)`, opacity: clamp(k * 3), textAlign: 'center', pointerEvents: 'none'}}>
|
|
141
146
|
<span
|
|
142
147
|
style={{
|
|
143
148
|
fontFamily: fuente,
|
|
144
149
|
fontWeight: 900,
|
|
145
|
-
fontSize: tam,
|
|
150
|
+
fontSize: pastilla ? tam * 0.82 : tam,
|
|
146
151
|
lineHeight: 1.05,
|
|
147
|
-
color: tema.colores.texto,
|
|
152
|
+
color: pastilla || estilo === 'contorno' ? '#FFFFFF' : tema.colores.texto,
|
|
148
153
|
textTransform: may ? 'uppercase' : undefined,
|
|
149
|
-
WebkitTextStroke: contorno ? `${
|
|
154
|
+
WebkitTextStroke: contorno ? `${grosor}px #1B1B1F` : undefined,
|
|
150
155
|
paintOrder: 'stroke fill',
|
|
151
|
-
textShadow: claro ? undefined : '0 6px 22px rgba(0,0,0,0.55)',
|
|
156
|
+
textShadow: pastilla ? undefined : estilo === 'contorno' ? '0 7px 0 rgba(0,0,0,0.28)' : claro ? undefined : '0 6px 22px rgba(0,0,0,0.55)',
|
|
157
|
+
...(pastilla ? {display: 'inline-block', background: '#1E1F24', padding: `${tam * 0.12}px ${tam * 0.32}px`, borderRadius: tam * 0.22, boxShadow: '0 10px 24px rgba(0,0,0,0.3)', border: '4px solid rgba(255,255,255,0.9)'} : {}),
|
|
152
158
|
}}
|
|
153
159
|
>
|
|
154
160
|
{partesResaltadas(texto).map(([p, r], i) => (
|
package/remotion/motor/capas.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import {Img, OffthreadVideo} from 'remotion';
|
|
|
5
5
|
import {Avatar, Icono, tamIcono, useMarca} from '../piezas';
|
|
6
6
|
import {H, W, backOut, clamp, easeIO, easeOut, easeOutExpo, lerp, resorte} from '../estilo';
|
|
7
7
|
import {ANOTACIONES, ANOTACIONES_SIN_ENTRADA, Anotacion} from './anotaciones';
|
|
8
|
+
import {FICHAS, Fichas} from './fichas';
|
|
8
9
|
import {azar, cuando, formatoNumero, partesResaltadas, recurso, type CtxEscena} from './util';
|
|
9
10
|
import {Particulas} from './fondo';
|
|
10
11
|
import {Escena3D, Modelo3D} from './modelo3d';
|
|
@@ -278,6 +279,7 @@ const Contenido: React.FC<{c: Capa; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
|
278
279
|
}
|
|
279
280
|
default:
|
|
280
281
|
if (ANOTACIONES.includes(c.tipo)) return <Anotacion c={c} ctx={ctx} />;
|
|
282
|
+
if (FICHAS.includes(c.tipo)) return <Fichas c={c} ctx={ctx} />;
|
|
281
283
|
return <div style={{color: 'red', fontSize: 40, fontFamily: 'sans-serif', background: '#fff', padding: 10}}>capa desconocida: {c.tipo}</div>;
|
|
282
284
|
}
|
|
283
285
|
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Tarjetas de datos para explicar con cosas que se entienden de un vistazo (estilo maqueta/infografía):
|
|
2
|
+
// reloj (carátula que se llena: horas del día, minutos), termometro, recipientes (garrafones o vasos que se llenan),
|
|
3
|
+
// ficha (credencial con foto y campos), checklist (se palomea en las palabras), mapa (plano con ruta y distancia).
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import {Img} from 'remotion';
|
|
6
|
+
import {clamp, easeOut, lerp, resorte} from '../estilo';
|
|
7
|
+
import {cuando, formatoNumero, recurso, type CtxEscena} from './util';
|
|
8
|
+
|
|
9
|
+
type C = Record<string, any>;
|
|
10
|
+
const tarjeta = (c: C, extra: React.CSSProperties = {}): React.CSSProperties => ({
|
|
11
|
+
background: c.fondo ?? '#FFFFFF',
|
|
12
|
+
border: `${c.grosor ?? 6}px solid ${c.borde ?? '#1B1B1F'}`,
|
|
13
|
+
borderRadius: 30,
|
|
14
|
+
boxShadow: '0 14px 30px rgba(0,0,0,0.28)',
|
|
15
|
+
fontFamily: 'Montserrat, sans-serif',
|
|
16
|
+
color: c.colorTexto ?? '#1B1B1F',
|
|
17
|
+
...extra,
|
|
18
|
+
});
|
|
19
|
+
/** 0..1 desde que empieza la capa (o desde `en`) durante `duracion` segundos. */
|
|
20
|
+
const avance = (c: C, ctx: CtxEscena, dur = 1.2, retraso = 0.25) => easeOut(clamp((ctx.t - cuando(c.en, ctx, 0) - retraso) / (c.duracion ?? dur)));
|
|
21
|
+
|
|
22
|
+
/** Reloj: un sector que se llena de `de` a `a` (horas de 0 a `max`, 24 por defecto). `segmentos` pinta tramos de colores. */
|
|
23
|
+
export const Reloj: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
24
|
+
const tam = c.tam ?? 300;
|
|
25
|
+
const max = c.max ?? 24;
|
|
26
|
+
const k = avance(c, ctx, c.duracion ?? 1.4);
|
|
27
|
+
const de = c.de ?? 0;
|
|
28
|
+
const a = lerp(de, c.a ?? c.valor ?? max / 4, k);
|
|
29
|
+
const r = tam / 2 - 22;
|
|
30
|
+
const cx = tam / 2;
|
|
31
|
+
const sector = (h0: number, h1: number, color: string, key: number) => {
|
|
32
|
+
if (h1 <= h0) return null;
|
|
33
|
+
const a0 = (h0 / max) * Math.PI * 2 - Math.PI / 2;
|
|
34
|
+
const a1 = (Math.min(h1, h0 + max * 0.9999) / max) * Math.PI * 2 - Math.PI / 2;
|
|
35
|
+
const p = (ang: number) => `${cx + Math.cos(ang) * r} ${cx + Math.sin(ang) * r}`;
|
|
36
|
+
return <path key={key} d={`M ${cx} ${cx} L ${p(a0)} A ${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${p(a1)} Z`} fill={color} />;
|
|
37
|
+
};
|
|
38
|
+
const segmentos: [number, number, string][] = c.segmentos ?? [[de, a, c.color ?? '#2D7BFF']];
|
|
39
|
+
const ang = (a / max) * 360;
|
|
40
|
+
return (
|
|
41
|
+
<div style={tarjeta(c, {width: tam + 40, padding: 20, borderRadius: 34, textAlign: 'center'})}>
|
|
42
|
+
<svg width={tam} height={tam}>
|
|
43
|
+
<circle cx={cx} cy={cx} r={r + 10} fill="#FFFFFF" stroke="#1B1B1F" strokeWidth={8} />
|
|
44
|
+
{segmentos.map(([h0, h1, color], i) => sector(h0, Math.min(h1, a), color, i))}
|
|
45
|
+
{Array.from({length: 12}, (_, i) => {
|
|
46
|
+
const g = (i / 12) * Math.PI * 2;
|
|
47
|
+
return <line key={i} x1={cx + Math.cos(g) * (r - 4)} y1={cx + Math.sin(g) * (r - 4)} x2={cx + Math.cos(g) * (r + 6)} y2={cx + Math.sin(g) * (r + 6)} stroke="#1B1B1F" strokeWidth={i % 3 ? 3 : 6} />;
|
|
48
|
+
})}
|
|
49
|
+
<line x1={cx} y1={cx} x2={cx + Math.cos(((ang - 90) * Math.PI) / 180) * r * 0.85} y2={cx + Math.sin(((ang - 90) * Math.PI) / 180) * r * 0.85} stroke="#1B1B1F" strokeWidth={8} strokeLinecap="round" />
|
|
50
|
+
<circle cx={cx} cy={cx} r={11} fill="#1B1B1F" />
|
|
51
|
+
</svg>
|
|
52
|
+
{c.etiqueta !== false ? <div style={{fontWeight: 900, fontSize: tam * 0.12, marginTop: 6}}>{c.etiqueta ?? `${max} H`}</div> : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Cronómetro: cuenta de `de` a `a` segundos (o minutos con `unidad: 'min'`) y su aro avanza. */
|
|
58
|
+
export const Cronometro: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
59
|
+
const tam = c.tam ?? 280;
|
|
60
|
+
const k = avance(c, ctx, c.duracion ?? 1.6);
|
|
61
|
+
const v = lerp(c.de ?? 0, c.a ?? 60, k);
|
|
62
|
+
const total = c.max ?? c.a ?? 60;
|
|
63
|
+
const r = tam / 2 - 26;
|
|
64
|
+
const cx = tam / 2;
|
|
65
|
+
const largo = 2 * Math.PI * r;
|
|
66
|
+
const mm = Math.floor(v / 60);
|
|
67
|
+
const ss = Math.floor(v % 60);
|
|
68
|
+
const texto = c.unidad === 'min' ? `${Math.round(v)} min` : `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
|
69
|
+
return (
|
|
70
|
+
<div style={{position: 'relative', width: tam, height: tam + 40}}>
|
|
71
|
+
<div style={{position: 'absolute', left: tam / 2 - 26, top: 0, width: 52, height: 34, background: c.color ?? '#E5484D', borderRadius: 10, border: '5px solid #1B1B1F'}} />
|
|
72
|
+
<svg width={tam} height={tam} style={{position: 'absolute', top: 36}}>
|
|
73
|
+
<circle cx={cx} cy={cx} r={r + 16} fill="#FFFFFF" stroke="#1B1B1F" strokeWidth={8} />
|
|
74
|
+
<circle cx={cx} cy={cx} r={r} fill="none" stroke="#E9ECF2" strokeWidth={18} />
|
|
75
|
+
<circle cx={cx} cy={cx} r={r} fill="none" stroke={c.color ?? '#E5484D'} strokeWidth={18} strokeDasharray={`${largo * clamp(v / total)} ${largo}`} transform={`rotate(-90 ${cx} ${cx})`} strokeLinecap="round" />
|
|
76
|
+
<text x={cx} y={cx + tam * 0.06} textAnchor="middle" fontFamily="Montserrat, sans-serif" fontWeight={900} fontSize={tam * 0.17} fill="#1B1B1F">
|
|
77
|
+
{texto}
|
|
78
|
+
</text>
|
|
79
|
+
</svg>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Termómetro que sube hasta `valor` (de `min` a `max`). */
|
|
85
|
+
export const Termometro: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
86
|
+
const alto = c.alto ?? 420;
|
|
87
|
+
const min = c.min ?? 0;
|
|
88
|
+
const max = c.max ?? 50;
|
|
89
|
+
const k = avance(c, ctx, 1.3);
|
|
90
|
+
const v = lerp(c.de ?? min, c.valor ?? 38, k);
|
|
91
|
+
const nivel = clamp((v - min) / (max - min));
|
|
92
|
+
const color = c.color ?? (nivel > 0.66 ? '#E5484D' : nivel > 0.4 ? '#F5B841' : '#2D7BFF');
|
|
93
|
+
const tubo = alto - 110;
|
|
94
|
+
return (
|
|
95
|
+
<div style={tarjeta(c, {width: 170, padding: '24px 0 18px', display: 'flex', flexDirection: 'column', alignItems: 'center'})}>
|
|
96
|
+
<div style={{position: 'relative', width: 60, height: tubo, background: '#EEF1F6', border: '6px solid #1B1B1F', borderRadius: 30, overflow: 'hidden'}}>
|
|
97
|
+
<div style={{position: 'absolute', left: 0, right: 0, bottom: 0, height: `${nivel * 100}%`, background: color}} />
|
|
98
|
+
</div>
|
|
99
|
+
<div style={{width: 92, height: 92, marginTop: -14, borderRadius: '50%', background: color, border: '6px solid #1B1B1F', zIndex: 1}} />
|
|
100
|
+
{c.mostrar !== false ? (
|
|
101
|
+
<div style={{fontWeight: 900, fontSize: 40, marginTop: 10}}>
|
|
102
|
+
{formatoNumero(v, c.decimales ?? 0)}
|
|
103
|
+
{c.unidad ?? '°C'}
|
|
104
|
+
</div>
|
|
105
|
+
) : null}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Recipientes (garrafones, vasos, cubetas) que se llenan uno tras otro hasta `valor` de `n`; `total` debajo ("50 L"). */
|
|
111
|
+
export const Recipientes: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
112
|
+
const n = c.n ?? 3;
|
|
113
|
+
const valor = c.valor ?? n;
|
|
114
|
+
const k = avance(c, ctx, c.duracion ?? 0.6 * n);
|
|
115
|
+
const lleno = valor * k;
|
|
116
|
+
const color = c.color ?? '#3B8CFF';
|
|
117
|
+
const forma = c.forma ?? 'garrafon';
|
|
118
|
+
const w = c.tamPieza ?? 110;
|
|
119
|
+
const h = forma === 'vaso' ? w * 1.2 : w * 1.6;
|
|
120
|
+
return (
|
|
121
|
+
<div style={tarjeta(c, {padding: '26px 30px 18px', display: 'inline-flex', flexDirection: 'column', alignItems: 'center'})}>
|
|
122
|
+
<div style={{display: 'flex', gap: 22, alignItems: 'flex-end'}}>
|
|
123
|
+
{Array.from({length: n}, (_, i) => {
|
|
124
|
+
const nivel = clamp(lleno - i);
|
|
125
|
+
return (
|
|
126
|
+
<div key={i} style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
|
|
127
|
+
{forma === 'garrafon' ? <div style={{width: w * 0.32, height: w * 0.22, background: color, border: '5px solid #1B1B1F', borderRadius: 6, marginBottom: -5}} /> : null}
|
|
128
|
+
<div style={{position: 'relative', width: w, height: h, border: '6px solid #1B1B1F', borderRadius: forma === 'vaso' ? '8px 8px 24px 24px' : `${w * 0.3}px ${w * 0.3}px 18px 18px`, background: '#F4F8FF', overflow: 'hidden'}}>
|
|
129
|
+
<div style={{position: 'absolute', left: 0, right: 0, bottom: 0, height: `${nivel * 100}%`, background: color, opacity: 0.9}} />
|
|
130
|
+
<div style={{position: 'absolute', left: '18%', top: '12%', width: '14%', height: '60%', background: 'rgba(255,255,255,0.55)', borderRadius: 20}} />
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
136
|
+
{c.total ? <div style={{fontWeight: 900, fontSize: 44, marginTop: 12}}>{c.total}</div> : null}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Ficha o credencial: cabecera de color, foto (imagen/recurso) a la izquierda y campos [etiqueta, valor].
|
|
143
|
+
* campos: [['NOMBRE', 'Buey prieto'], ['SEXO', 'Macho']]
|
|
144
|
+
*/
|
|
145
|
+
export const Ficha: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
146
|
+
const ancho = c.ancho ?? 640;
|
|
147
|
+
const campos: [string, string][] = c.campos ?? [];
|
|
148
|
+
const t0 = cuando(c.en, ctx, 0);
|
|
149
|
+
return (
|
|
150
|
+
<div style={tarjeta(c, {width: ancho, overflow: 'hidden', padding: 0, borderRadius: 26})}>
|
|
151
|
+
<div style={{background: c.color ?? '#7A4FD1', color: '#FFFFFF', fontWeight: 900, fontSize: 30, letterSpacing: 1.5, textAlign: 'center', padding: '12px 10px', textTransform: 'uppercase'}}>{c.titulo ?? 'Credencial'}</div>
|
|
152
|
+
<div style={{display: 'flex', gap: 22, padding: 22, alignItems: 'center'}}>
|
|
153
|
+
{c.foto ? (
|
|
154
|
+
<div style={{width: ancho * 0.3, height: ancho * 0.3, borderRadius: 18, border: '5px solid #1B1B1F', background: c.fondoFoto ?? '#EDE6F7', overflow: 'hidden', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
|
|
155
|
+
<Img src={recurso(c.foto)} style={{width: '90%', height: '90%', objectFit: 'contain'}} />
|
|
156
|
+
</div>
|
|
157
|
+
) : null}
|
|
158
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
|
159
|
+
{campos.map(([et, v], i) => {
|
|
160
|
+
const k = clamp((ctx.t - t0 - 0.3 - i * 0.18) / 0.3);
|
|
161
|
+
return (
|
|
162
|
+
<div key={i} style={{opacity: k, transform: `translateX(${(1 - k) * 20}px)`}}>
|
|
163
|
+
<div style={{fontSize: 20, fontWeight: 700, color: '#8A8A99', letterSpacing: 1, textTransform: 'uppercase'}}>{et}</div>
|
|
164
|
+
<div style={{fontSize: 34, fontWeight: 900, lineHeight: 1.1, textTransform: 'uppercase'}}>{v}</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Lista que se palomea: items [{texto, en: 'palabra'}] (o texto suelto). Los que no tienen `en` se palomean en orden.
|
|
176
|
+
* Con `pendiente: true` un item queda gris y sin palomita (lo que falta).
|
|
177
|
+
*/
|
|
178
|
+
export const Checklist: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
179
|
+
const items: {texto: string; en?: unknown; pendiente?: boolean}[] = (c.items ?? []).map((x: any) => (typeof x === 'string' ? {texto: x} : x));
|
|
180
|
+
const t0 = cuando(c.en, ctx, 0);
|
|
181
|
+
const tam = c.tam ?? 46;
|
|
182
|
+
return (
|
|
183
|
+
<div style={tarjeta(c, {padding: '22px 32px', display: 'inline-flex', flexDirection: 'column', gap: 10})}>
|
|
184
|
+
{c.titulo ? <div style={{fontWeight: 900, fontSize: tam * 0.7, color: '#8A8A99', letterSpacing: 1, textTransform: 'uppercase'}}>{c.titulo}</div> : null}
|
|
185
|
+
{items.map((it, i) => {
|
|
186
|
+
const tc = it.en != null ? cuando(it.en, ctx, 0) : t0 + 0.4 + i * (c.cada ?? 0.45);
|
|
187
|
+
const k = it.pendiente ? 0 : clamp((ctx.t - tc) / 0.25);
|
|
188
|
+
const color = it.pendiente ? '#B9BCC6' : c.color ?? '#2BB673';
|
|
189
|
+
return (
|
|
190
|
+
<div key={i} style={{display: 'flex', alignItems: 'center', gap: 18, fontWeight: 900, fontSize: tam, color: it.pendiente ? '#B9BCC6' : '#1B1B1F', textTransform: 'uppercase'}}>
|
|
191
|
+
<span style={{color}}>{it.texto}</span>
|
|
192
|
+
<svg width={tam * 0.9} height={tam * 0.9} viewBox="0 0 40 40" style={{transform: `scale(${resorte(k, 0.4)})`}}>
|
|
193
|
+
<path d="M 6 21 L 16 31 L 35 9" stroke={color} strokeWidth={7} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
194
|
+
</svg>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Mapa mini: un plano inclinado con una ruta que se dibuja de `de` a `a` (coordenadas 0..1 del plano) y la distancia.
|
|
204
|
+
* `ruta`: puntos intermedios [[x, y], ...]; `distancia`: '850 M'.
|
|
205
|
+
*/
|
|
206
|
+
export const Mapa: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
207
|
+
const ancho = c.ancho ?? 620;
|
|
208
|
+
const alto = ancho * 0.62;
|
|
209
|
+
const k = avance(c, ctx, c.duracion ?? 1.4, 0.4);
|
|
210
|
+
const pts: [number, number][] = c.ruta ?? [
|
|
211
|
+
[0.12, 0.7],
|
|
212
|
+
[0.35, 0.45],
|
|
213
|
+
[0.6, 0.55],
|
|
214
|
+
[0.88, 0.25],
|
|
215
|
+
];
|
|
216
|
+
const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'} ${x * ancho} ${y * alto}`).join(' ');
|
|
217
|
+
const largo = pts.reduce((a, [x, y], i) => (i ? a + Math.hypot((x - pts[i - 1][0]) * ancho, (y - pts[i - 1][1]) * alto) : 0), 0);
|
|
218
|
+
const fin = pts[pts.length - 1];
|
|
219
|
+
return (
|
|
220
|
+
<div style={{position: 'relative', width: ancho, height: alto, transform: `perspective(1400px) rotateX(${c.inclinar ?? 38}deg) rotateZ(-8deg)`, transformOrigin: 'center'}}>
|
|
221
|
+
<svg width={ancho} height={alto} style={{background: c.fondo ?? '#7CC46A', borderRadius: 18, border: '8px solid #FFFFFF', boxShadow: '0 30px 40px rgba(0,0,0,0.25)'}}>
|
|
222
|
+
{Array.from({length: 40}, (_, i) => (
|
|
223
|
+
<circle key={i} cx={((i * 97) % 100) / 100 * ancho} cy={((i * 61) % 100) / 100 * alto} r={10 + (i % 4) * 4} fill="#5BA84C" opacity={0.7} />
|
|
224
|
+
))}
|
|
225
|
+
<path d={d} stroke={c.color ?? '#2D7BFF'} strokeWidth={22} fill="none" strokeLinecap="round" strokeLinejoin="round" strokeDasharray={`${largo * k} ${largo}`} />
|
|
226
|
+
{k > 0.98 ? <circle cx={fin[0] * ancho} cy={fin[1] * alto} r={18} fill="#E5484D" stroke="#FFFFFF" strokeWidth={6} /> : null}
|
|
227
|
+
</svg>
|
|
228
|
+
{c.distancia ? (
|
|
229
|
+
<div style={{position: 'absolute', left: 0, right: 0, top: alto * 0.08, textAlign: 'center', fontFamily: 'Montserrat, sans-serif', fontWeight: 900, fontSize: 54, color: '#FFFFFF', textShadow: '0 4px 0 rgba(0,0,0,0.35)', opacity: k}}>
|
|
230
|
+
{c.distancia}
|
|
231
|
+
</div>
|
|
232
|
+
) : null}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const FICHAS = ['reloj', 'cronometro', 'termometro', 'recipientes', 'ficha', 'checklist', 'mapa'];
|
|
238
|
+
export const Fichas: React.FC<{c: C; ctx: CtxEscena}> = ({c, ctx}) => {
|
|
239
|
+
switch (c.tipo) {
|
|
240
|
+
case 'reloj':
|
|
241
|
+
return <Reloj c={c} ctx={ctx} />;
|
|
242
|
+
case 'cronometro':
|
|
243
|
+
return <Cronometro c={c} ctx={ctx} />;
|
|
244
|
+
case 'termometro':
|
|
245
|
+
return <Termometro c={c} ctx={ctx} />;
|
|
246
|
+
case 'recipientes':
|
|
247
|
+
return <Recipientes c={c} ctx={ctx} />;
|
|
248
|
+
case 'ficha':
|
|
249
|
+
return <Ficha c={c} ctx={ctx} />;
|
|
250
|
+
case 'checklist':
|
|
251
|
+
return <Checklist c={c} ctx={ctx} />;
|
|
252
|
+
default:
|
|
253
|
+
return <Mapa c={c} ctx={ctx} />;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
@@ -19,7 +19,7 @@ import {OBJETOS_3D, Objeto3D} from './objetos3d';
|
|
|
19
19
|
const RAD = Math.PI / 180;
|
|
20
20
|
type Gltf = {scene: THREE.Group; animations: THREE.AnimationClip[]};
|
|
21
21
|
const cache = new Map<string, Promise<Gltf>>();
|
|
22
|
-
const cargarGltf = (url: string) => {
|
|
22
|
+
export const cargarGltf = (url: string) => {
|
|
23
23
|
if (!cache.has(url)) cache.set(url, new GLTFLoader().loadAsync(url).then((g) => ({scene: g.scene, animations: g.animations})));
|
|
24
24
|
return cache.get(url)!;
|
|
25
25
|
};
|
|
@@ -74,7 +74,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
74
74
|
if (!g || t < g[0][1] - 0.12) return null;
|
|
75
75
|
const entrada = clamp((t - (g[0][1] - 0.12)) / 0.18);
|
|
76
76
|
const clave = claveDe(g, claves);
|
|
77
|
-
const texto = (w: string) => (S.mayusculas ? w.toUpperCase() : w);
|
|
77
|
+
const texto = (w: string) => (S.mayusculas === 'inicial' ? w.charAt(0).toUpperCase() + w.slice(1) : S.mayusculas ? w.toUpperCase() : w);
|
|
78
78
|
|
|
79
79
|
if (S.tipo === 'palabra') {
|
|
80
80
|
const [w] = g[0];
|
|
@@ -82,7 +82,7 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
|
|
|
82
82
|
const acento = claveDe([g[0]], claves) === 0;
|
|
83
83
|
return (
|
|
84
84
|
<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: acento ? tema.colores.acento : S.color, WebkitTextStroke: `${S.contorno ?? 9}px #000`, paintOrder: 'stroke fill', textShadow: '0 6px 0 rgba(0,0,0,0.35)'}}>
|
|
85
|
+
<span style={{fontFamily: tema.fuentes.texto, fontWeight: 900, fontSize: S.tam * 1.25, color: acento ? tema.colores.acento : S.color, 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
86
|
{texto(w)}
|
|
87
87
|
</span>
|
|
88
88
|
</div>
|
package/remotion/motor/tema.ts
CHANGED
|
@@ -38,7 +38,9 @@ export type Tema = {
|
|
|
38
38
|
* palabra: una palabra a la vez · caja: la frase completa en una etiqueta clara.
|
|
39
39
|
*/
|
|
40
40
|
tipo: 'karaoke' | 'seleccion' | 'pop' | 'bloque' | 'palabra' | 'caja' | 'ninguno';
|
|
41
|
-
|
|
41
|
+
/** true = MAYÚSCULAS · 'inicial' = Primera Letra (como en los videos de maqueta) */
|
|
42
|
+
mayusculas?: boolean | 'inicial';
|
|
43
|
+
/** 0 = sin contorno: sombra suave en su lugar */
|
|
42
44
|
contorno?: number;
|
|
43
45
|
fondo?: string; // caja detrás de la frase (karaoke)
|
|
44
46
|
y: number; // centro vertical (px de 1920)
|
|
@@ -58,6 +60,8 @@ export type Tema = {
|
|
|
58
60
|
desenfoqueBordes?: number; // px de blur arriba y abajo (efecto maqueta / profundidad)
|
|
59
61
|
vinetaColor?: string;
|
|
60
62
|
};
|
|
63
|
+
/** Rótulo grande de cada escena: 'normal' (color del texto), 'contorno' (blanco con contorno oscuro, clave en acento) o 'pastilla' (caja oscura inclinada). */
|
|
64
|
+
rotulo?: {estilo?: 'normal' | 'contorno' | 'pastilla'; inclinar?: number; y?: number; tam?: number};
|
|
61
65
|
marca: 'esquina' | 'arriba' | 'ninguna';
|
|
62
66
|
/** Movimiento suave de cámara por defecto en cada escena (zoom de 1 a este valor). */
|
|
63
67
|
zoomLento: number;
|
|
@@ -197,6 +201,21 @@ export const ESTILOS: Record<string, Tema> = {
|
|
|
197
201
|
marca: 'esquina',
|
|
198
202
|
zoomLento: 1.04,
|
|
199
203
|
},
|
|
204
|
+
// Maqueta 3D low-poly explicada (diorama con corte de tierra): una palabra a la vez al centro, rótulos con contorno,
|
|
205
|
+
// etiquetas con flecha pegadas a los objetos y cortes rápidos entre ángulos del mismo mundo
|
|
206
|
+
maqueta: {
|
|
207
|
+
fondo: {tipo: 'degradado', colores: ['#BFE3FF', '#EAF6FF']},
|
|
208
|
+
vineta: 0.06,
|
|
209
|
+
colores: {texto: '#FFFFFF', acento: '#FFC928', acento2: '#2D7BFF', suave: '#E8EEF6', tarjeta: '#FFFFFF', tarjetaTexto: '#1B1B1F', borde: '#1B1B1F'},
|
|
210
|
+
fuentes: {titulo: MONT, texto: MONT, mono: MONO},
|
|
211
|
+
tituloMayusculas: true,
|
|
212
|
+
subtitulos: {tipo: 'palabra', y: 1010, tam: 52, maxPalabras: 1, mayusculas: 'inicial', contorno: 0, color: '#FFFFFF', pendiente: '#FFFFFF', resalte: ''},
|
|
213
|
+
rotulo: {estilo: 'contorno', inclinar: -2, y: 330, tam: 92},
|
|
214
|
+
transicion: 'corte',
|
|
215
|
+
movimiento: {entrada: 0.4, salida: 0.25, transicion: 0.2, curva: 'resorte'},
|
|
216
|
+
marca: 'ninguna',
|
|
217
|
+
zoomLento: 1.03,
|
|
218
|
+
},
|
|
200
219
|
// Oscuro y limpio, para lo que sea
|
|
201
220
|
minimal: {
|
|
202
221
|
fondo: {tipo: 'degradado', colores: ['#15151C', '#08080C']},
|