morfeo 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/python/musica.py CHANGED
@@ -175,20 +175,50 @@ def efecto(nombre, rng):
175
175
  if nombre == "tick": return 0.4 * tick()
176
176
  if nombre == "glitch": return 0.3 * glitch(rng)
177
177
  if nombre == "ding": return 0.2 * bell(nf(88), 0.8)
178
+ if nombre == "golpe":
179
+ x = 0.22 * pop(180, 90, 0.16)
180
+ add(x, 0.1 * lp(rng.standard_normal(int(0.06 * SR)), 1200), 0)
181
+ return x
178
182
  return 0.14 * pop()
179
183
 
180
184
 
185
+ # sonido por defecto según cómo entra o qué es la capa (sonido: false lo apaga; sonido: "x" lo cambia)
186
+ SONIDO_TIPO = {"sello": "golpe", "dato": "tick", "contador": "tick", "nombre": "pop", "accion": "pop", "chip": "pop", "insignia": "pop"}
187
+ SONIDO_ENTRADA = {"rebote": "pop", "caer": "golpe", "deslizar-izq": "whoosh", "deslizar-der": "whoosh", "deslizar-arriba": "whoosh", "deslizar-abajo": "whoosh", "girar": "whoosh"}
188
+
189
+
190
+ def sonido_de(c):
191
+ s = c.get("sonido")
192
+ if s is False or s == "ninguno": return None
193
+ if s: return s
194
+ return SONIDO_TIPO.get(c.get("tipo")) or SONIDO_ENTRADA.get(c.get("entrada"))
195
+
196
+
181
197
  def capas_con_sonido(sp):
182
- """Capas de la escena (y de sus grupos) que piden un efecto de sonido."""
198
+ """[(capa, efecto, explícito)] de la escena y de sus grupos; las de enMundo y las del HUD no suenan solas."""
183
199
  out, pila = [], list(sp.get("capas") or [])
184
200
  while pila:
185
201
  c = pila.pop(0)
186
202
  if not isinstance(c, dict): continue
187
- if c.get("sonido"): out.append(c)
203
+ ef = sonido_de(c)
204
+ if ef: out.append((c, ef, bool(c.get("sonido"))))
188
205
  pila.extend(c.get("capas") or [])
189
206
  return out
190
207
 
191
208
 
209
+ def momento(en, ev, si, m):
210
+ """Segundos (relativos a la escena) de `en`: número, palabra, "palabra+0.3", "inicio", "voz" o "fin" (como cuando() del motor)."""
211
+ if en is None: return 0.3
212
+ if isinstance(en, (int, float)): return float(en)
213
+ import re
214
+ g = re.match(r"^(.*?)([+-]\d+(?:\.\d+)?)?$", str(en))
215
+ base, ajuste = (g.group(1) or "").strip(), float(g.group(2) or 0)
216
+ if base in ("", "inicio"): return ajuste
217
+ if base == "voz": return m["start"] - m["t0"] + ajuste
218
+ if base == "fin": return m["start"] - m["t0"] + m["speech"] + ajuste
219
+ return ev(si, base) + ajuste
220
+
221
+
192
222
  def mix(vid, style, outdir, TIM, SPEC, durs, ev):
193
223
  """Mezcla voz + música (con ducking) + efectos y escribe mix.wav. style=None: sin música."""
194
224
  dur = sum(durs); N = int(dur * SR); rng = np.random.default_rng(11)
@@ -206,11 +236,14 @@ def mix(vid, style, outdir, TIM, SPEC, durs, ev):
206
236
  if ty == "text" and sp.get("strike"):
207
237
  for i in range(len(sp["rows"])): add(sfx, 0.2 * whoosh(rng, 0.15), t0 + m["speech"] * 0.55 + i * 0.12)
208
238
  if ty == "cta": add(sfx, 0.4 * tick(), t0 + 1.6)
209
- # motor de capas: cada capa (o grupo) con "sonido" suena cuando entra
210
- for capa in capas_con_sonido(sp):
211
- cuando = capa.get("en", 0.3)
212
- tc = t0 + (ev(si, cuando) if isinstance(cuando, str) else float(cuando))
213
- add(sfx, efecto(capa["sonido"], rng), tc)
239
+ # motor de capas: cada capa suena cuando entra (las explícitas siempre; las automáticas, más bajas y sin amontonarse)
240
+ if sp.get("rotulo"): add(sfx, 0.5 * efecto("whoosh", rng), t0 + momento(sp.get("rotuloEn", 0.12), ev, si, m) - 0.05)
241
+ usados = []
242
+ for capa, ef, explicito in sorted(capas_con_sonido(sp), key=lambda x: momento(x[0].get("en"), ev, si, m)):
243
+ tc = t0 + momento(capa.get("en"), ev, si, m)
244
+ if not explicito and any(abs(tc - u) < 0.25 for u in usados): continue
245
+ usados.append(tc)
246
+ add(sfx, (1.0 if explicito else 0.6) * efecto(ef, rng), tc)
214
247
  voice, _ = sf.read(os.path.join(outdir, "voz.wav"))
215
248
  voice = resample_poly(voice, 147, 80)
216
249
  v = np.zeros(N); v[: min(N, len(voice))] = voice[:N]
@@ -228,16 +228,25 @@ def preparar(job):
228
228
  if not voz_vigente(v, outdir):
229
229
  print(" voz...", flush=True); hacer_voz(v, outdir, job["modelos"])
230
230
  if job.get("soloVoz"): return
231
+ # pidió Whisper y la transcripción no existe (voz nueva): se sale con 10 para que morfeo transcriba y vuelva a llamar
232
+ if job.get("conWhisper") and not os.path.exists(os.path.join(outdir, "whisper.json")): sys.exit(10)
231
233
  TIM = json.load(open(os.path.join(outdir, "tiempos.json"), encoding="utf-8"))["escenas"]
232
234
  wj = os.path.join(outdir, "whisper.json"); ww = palabras_whisper(wj) if os.path.exists(wj) else None
233
235
  WORDS, reales = [], 0
234
236
  for seg in TIM:
235
237
  w, ok = tiempos_palabras(seg, ww); WORDS.append(w); reales += ok
236
238
 
239
+ def norm(x):
240
+ # igual que norm() de remotion/motor/util.ts: sin acentos, minúsculas, solo letras y números
241
+ import unicodedata
242
+ x = unicodedata.normalize("NFD", str(x))
243
+ return "".join(ch for ch in x if not unicodedata.combining(ch)).lower().translate({ord(c): None for c in "¿?.,:;¡!\"'«»()"}).replace(" ", "")
244
+
237
245
  def ev(si, prefix):
238
246
  seg = TIM[si]
247
+ p = norm(prefix)
239
248
  for w, a, b in WORDS[si]:
240
- if w.lower().strip("¿?.,:¡!").startswith(prefix.lower()): return a - seg["t0"]
249
+ if norm(w).startswith(p): return a - seg["t0"]
241
250
  return seg["speech"] * 0.5
242
251
 
243
252
  durs = [seg["dur"] + (0.6 if i == len(TIM) - 1 else 0) for i, seg in enumerate(TIM)]
@@ -0,0 +1,180 @@
1
+ // Gráficas 2D que se dibujan solas (van DENTRO de un <svg>): barras, línea, comparación y lupa.
2
+ // `t` es el tiempo y `en` el momento en que empiezan (segundos); todo se anima con el tiempo.
3
+ //
4
+ // <GraficaBarras x={140} y={700} ancho={800} alto={500} t={t} en={1} datos={[['0 m', 100], ['1 m', 50], ['2 m', 25]]} />
5
+ // <GraficaLinea x={140} y={700} ancho={800} alto={420} t={t} puntos={[100, 50, 25, 12]} etiquetas={['0', '1', '2', '3']} />
6
+ // <Comparacion x={540} y={1300} t={t} a={['ANTES', 10]} b={['DESPUÉS', 27.4]} unidad="kg" />
7
+ // <Lupa x={540} y={900} radio={220} t={t}> ...lo que se ve aumentado... </Lupa>
8
+ import React from 'react';
9
+
10
+ const k = (t: number, en: number, dur: number) => {
11
+ const x = Math.max(0, Math.min(1, (t - en) / dur));
12
+ return 1 - (1 - x) ** 3;
13
+ };
14
+ const fmt = (v: number, dec = 0) => v.toLocaleString('es-MX', {minimumFractionDigits: dec, maximumFractionDigits: dec});
15
+
16
+ type Comun = {x: number; y: number; t: number; en?: number; color?: string; texto?: string; fuente?: string};
17
+
18
+ /** Barras horizontales: [etiqueta, valor]. La más grande ocupa todo el ancho; el valor cuenta mientras crece. */
19
+ export const GraficaBarras: React.FC<Comun & {ancho?: number; alto?: number; datos: [string, number][]; unidad?: string; colores?: string[]; decimales?: number; maximo?: number}> = ({
20
+ x,
21
+ y,
22
+ t,
23
+ en = 0,
24
+ ancho = 820,
25
+ alto = 480,
26
+ datos,
27
+ unidad = '',
28
+ color = '#F2B632',
29
+ colores,
30
+ texto = '#FFFFFF',
31
+ fuente = 'JetBrains Mono, monospace',
32
+ decimales = 0,
33
+ maximo,
34
+ }) => {
35
+ const max = maximo ?? Math.max(...datos.map(([, v]) => v), 1);
36
+ const fila = alto / datos.length;
37
+ const etiqueta = 150;
38
+ return (
39
+ <g transform={`translate(${x} ${y})`}>
40
+ {datos.map(([e, v], i) => {
41
+ const p = k(t, en + i * 0.18, 0.9);
42
+ const w = (ancho - etiqueta - 150) * (v / max) * p;
43
+ return (
44
+ <g key={i} transform={`translate(0 ${i * fila})`} opacity={Math.min(1, p * 4)}>
45
+ <text x={etiqueta - 20} y={fila * 0.42} textAnchor="end" dominantBaseline="middle" fontFamily={fuente} fontSize={fila * 0.32} fill={texto} opacity={0.75}>
46
+ {e}
47
+ </text>
48
+ <rect x={etiqueta} y={fila * 0.18} width={Math.max(4, w)} height={fila * 0.48} rx={fila * 0.1} fill={colores?.[i] ?? color} />
49
+ <text x={etiqueta + w + 16} y={fila * 0.42} dominantBaseline="middle" fontFamily={fuente} fontWeight={700} fontSize={fila * 0.32} fill={texto}>
50
+ {fmt(v * p, decimales)}
51
+ {unidad}
52
+ </text>
53
+ </g>
54
+ );
55
+ })}
56
+ </g>
57
+ );
58
+ };
59
+
60
+ /** Línea que se dibuja de izquierda a derecha, con puntos y etiquetas en el eje. */
61
+ export const GraficaLinea: React.FC<Comun & {ancho?: number; alto?: number; puntos: number[]; etiquetas?: string[]; minimo?: number; maximo?: number; relleno?: boolean; dur?: number}> = ({
62
+ x,
63
+ y,
64
+ t,
65
+ en = 0,
66
+ ancho = 820,
67
+ alto = 420,
68
+ puntos,
69
+ etiquetas = [],
70
+ color = '#F07A3A',
71
+ texto = '#FFFFFF',
72
+ fuente = 'JetBrains Mono, monospace',
73
+ minimo,
74
+ maximo,
75
+ relleno = true,
76
+ dur = 1.4,
77
+ }) => {
78
+ const mn = minimo ?? Math.min(0, ...puntos);
79
+ const mx = maximo ?? Math.max(...puntos);
80
+ const px = (i: number) => (i / Math.max(1, puntos.length - 1)) * ancho;
81
+ const py = (v: number) => alto - ((v - mn) / Math.max(1e-6, mx - mn)) * alto;
82
+ const p = k(t, en, dur);
83
+ const d = puntos.map((v, i) => `${i ? 'L' : 'M'} ${px(i)} ${py(v)}`).join(' ');
84
+ const largo = puntos.slice(1).reduce((s, v, i) => s + Math.hypot(px(i + 1) - px(i), py(v) - py(puntos[i])), 0);
85
+ const id = `linea-${Math.round(x)}-${Math.round(y)}`;
86
+ return (
87
+ <g transform={`translate(${x} ${y})`}>
88
+ <line x1={0} y1={alto} x2={ancho} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
89
+ <line x1={0} y1={0} x2={0} y2={alto} stroke={texto} strokeOpacity={0.35} strokeWidth={3} />
90
+ {relleno ? (
91
+ <>
92
+ <defs>
93
+ <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
94
+ <stop offset="0" stopColor={color} stopOpacity={0.35} />
95
+ <stop offset="1" stopColor={color} stopOpacity={0} />
96
+ </linearGradient>
97
+ <clipPath id={`${id}-c`}>
98
+ <rect x={0} y={-50} width={ancho * p} height={alto + 100} />
99
+ </clipPath>
100
+ </defs>
101
+ <path d={`${d} L ${ancho} ${alto} L 0 ${alto} Z`} fill={`url(#${id})`} clipPath={`url(#${id}-c)`} />
102
+ </>
103
+ ) : null}
104
+ <path d={d} fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={largo} strokeDashoffset={largo * (1 - p)} />
105
+ {puntos.map((v, i) =>
106
+ px(i) <= ancho * p + 1 ? <circle key={i} cx={px(i)} cy={py(v)} r={11} fill={color} stroke="#fff" strokeWidth={4} /> : null,
107
+ )}
108
+ {etiquetas.map((e, i) => (
109
+ <text key={i} x={px(i)} y={alto + 44} textAnchor="middle" fontFamily={fuente} fontSize={28} fill={texto} opacity={0.7}>
110
+ {e}
111
+ </text>
112
+ ))}
113
+ </g>
114
+ );
115
+ };
116
+
117
+ /** Dos columnas que crecen hasta su valor (antes / después, A contra B), con el número contando encima. */
118
+ export const Comparacion: React.FC<Comun & {a: [string, number]; b: [string, number]; unidad?: string; alto?: number; colorA?: string; colorB?: string; decimales?: number}> = ({
119
+ x,
120
+ y,
121
+ t,
122
+ en = 0,
123
+ a,
124
+ b,
125
+ unidad = '',
126
+ alto = 520,
127
+ colorA = '#8A8F9C',
128
+ colorB = '#2FD08A',
129
+ texto = '#FFFFFF',
130
+ fuente = 'Montserrat, sans-serif',
131
+ decimales = 0,
132
+ }) => {
133
+ const max = Math.max(a[1], b[1], 1e-6);
134
+ const col = (lbl: string, v: number, c: string, dx: number, retraso: number) => {
135
+ const p = k(t, en + retraso, 1.1);
136
+ const h = (alto * v * p) / max;
137
+ return (
138
+ <g transform={`translate(${dx} 0)`}>
139
+ <rect x={-90} y={-h} width={180} height={Math.max(2, h)} rx={18} fill={c} />
140
+ <text x={0} y={-h - 28} textAnchor="middle" fontFamily={fuente} fontWeight={900} fontSize={64} fill={texto}>
141
+ {fmt(v * p, decimales)}
142
+ <tspan fontSize={34}>{unidad}</tspan>
143
+ </text>
144
+ <text x={0} y={56} textAnchor="middle" fontFamily={fuente} fontWeight={800} fontSize={36} fill={texto} opacity={0.8}>
145
+ {lbl}
146
+ </text>
147
+ </g>
148
+ );
149
+ };
150
+ return (
151
+ <g transform={`translate(${x} ${y})`}>
152
+ <line x1={-280} y1={0} x2={280} y2={0} stroke={texto} strokeOpacity={0.4} strokeWidth={4} />
153
+ {col(a[0], a[1], colorA, -150, 0)}
154
+ {col(b[0], b[1], colorB, 150, 0.5)}
155
+ </g>
156
+ );
157
+ };
158
+
159
+ /** Lupa: aro con mango; dentro se ve `children` (lo que dibujes, normalmente más grande). Entra con un pequeño rebote. */
160
+ export const Lupa: React.FC<{x: number; y: number; radio?: number; t: number; en?: number; fondo?: string; children?: React.ReactNode}> = ({x, y, radio = 220, t, en = 0, fondo = '#F4E6D8', children}) => {
161
+ const p = k(t, en, 0.5);
162
+ const s = 0.6 + 0.4 * p + Math.sin(Math.min(1, (t - en) / 0.5) * Math.PI) * 0.05;
163
+ const id = `lupa-${Math.round(x)}-${Math.round(y)}`;
164
+ if (p <= 0) return null;
165
+ return (
166
+ <g transform={`translate(${x} ${y}) scale(${s})`} opacity={Math.min(1, p * 3)}>
167
+ <defs>
168
+ <clipPath id={id}>
169
+ <circle r={radio} />
170
+ </clipPath>
171
+ </defs>
172
+ <rect x={radio * 0.62} y={radio * 0.62} width={radio * 0.3} height={radio * 0.95} rx={radio * 0.12} transform={`rotate(-45 ${radio * 0.77} ${radio * 0.62})`} fill="#5A3A28" />
173
+ <circle r={radio} fill={fondo} />
174
+ <g clipPath={`url(#${id})`}>{children}</g>
175
+ <circle r={radio} fill="none" stroke="#E9EDF2" strokeWidth={radio * 0.11} />
176
+ <circle r={radio * 1.05} fill="none" stroke="rgba(0,0,0,0.25)" strokeWidth={4} />
177
+ <path d={`M ${-radio * 0.55} ${-radio * 0.35} A ${radio * 0.7} ${radio * 0.7} 0 0 1 ${-radio * 0.2} ${-radio * 0.62}`} stroke="rgba(255,255,255,0.7)" strokeWidth={10} fill="none" strokeLinecap="round" />
178
+ </g>
179
+ );
180
+ };
@@ -0,0 +1,250 @@
1
+ // Escenarios 2D listos (van DENTRO de un <svg>, normalmente como fondo de un `mundo` de 2160×1920).
2
+ // Todos aceptan `ancho` (del mundo), `suelo` (altura del piso), `t` (para lo que se mueve) y `camara` (paralaje).
3
+ // Combínalos con Persona2D, Multitud y Globo (kit/personas.tsx) y con las anotaciones del motor.
4
+ //
5
+ // <CiudadNoche t={t} camara={camara} /> <Cuarto pared="#2E5A4C" /> <Campo t={t} /> <Calle t={t} />
6
+ // <Oficina /> <Mar t={t} profundo />
7
+ import React from 'react';
8
+ import {Cielo, ConoLuz, Estrellas, Montanas, Paralaje, Pasto, Pinos, Suelo, alturaSuelo, azarN, type Camara} from './ilustrado';
9
+
10
+ type Base = {ancho?: number; suelo?: number; t?: number; camara?: Camara; semilla?: number};
11
+
12
+ /** Edificios con ventanas (algunas encendidas que parpadean poco a poco). */
13
+ const Edificios: React.FC<{x0: number; x1: number; base: number; alto: number; color: string; luz: string; t: number; semilla: number; n?: number}> = ({x0, x1, base, alto, color, luz, t, semilla, n = 9}) => {
14
+ const ancho = (x1 - x0) / n;
15
+ return (
16
+ <g>
17
+ {Array.from({length: n}, (_, i) => {
18
+ const s = semilla + i * 13;
19
+ const w = ancho * (0.7 + azarN(s) * 0.28);
20
+ const h = alto * (0.45 + azarN(s + 1) * 0.55);
21
+ const x = x0 + i * ancho + (ancho - w) / 2;
22
+ const cols = Math.max(2, Math.floor(w / 46));
23
+ const filas = Math.max(3, Math.floor(h / 62));
24
+ return (
25
+ <g key={i}>
26
+ <rect x={x} y={base - h} width={w} height={h + 400} fill={color} />
27
+ {Array.from({length: cols * filas}, (_, j) => {
28
+ const c = j % cols;
29
+ const f = Math.floor(j / cols);
30
+ const on = azarN(s + j * 3) > 0.55;
31
+ if (!on) return null;
32
+ const brillo = 0.75 + 0.25 * Math.sin(t * (0.3 + azarN(s + j) * 0.5) + j);
33
+ return <rect key={j} x={x + 14 + c * ((w - 28) / cols)} y={base - h + 22 + f * 62} width={(w - 28) / cols - 14} height={34} rx={3} fill={luz} opacity={brillo} />;
34
+ })}
35
+ </g>
36
+ );
37
+ })}
38
+ </g>
39
+ );
40
+ };
41
+
42
+ /** Ciudad de noche: cielo, estrellas, edificios en tres planos con ventanas, calle. */
43
+ export const CiudadNoche: React.FC<Base & {luz?: string}> = ({ancho = 2160, suelo = 1500, t = 0, camara, semilla = 0, luz = '#FFD27A'}) => (
44
+ <g>
45
+ <Paralaje camara={camara} profundidad={0.9}>
46
+ <Cielo id="cn-cielo" colores={['#070A1F', '#141B45', '#2B2A63', '#4A3A73']} />
47
+ <Estrellas t={t} n={80} y1={900} semilla={semilla} />
48
+ </Paralaje>
49
+ <Paralaje camara={camara} profundidad={0.55}>
50
+ <Edificios x0={-500} x1={ancho + 500} base={suelo - 60} alto={900} color="#1B2350" luz="#6C77C9" t={t} semilla={semilla + 1} n={14} />
51
+ </Paralaje>
52
+ <Paralaje camara={camara} profundidad={0.25}>
53
+ <Edificios x0={-400} x1={ancho + 400} base={suelo - 10} alto={1050} color="#141A3A" luz={luz} t={t} semilla={semilla + 2} n={10} />
54
+ </Paralaje>
55
+ <rect x={-800} y={suelo} width={ancho + 1600} height={800} fill="#20222C" />
56
+ <rect x={-800} y={suelo} width={ancho + 1600} height={14} fill="#3A3D4A" />
57
+ {Array.from({length: Math.ceil((ancho + 1600) / 240)}, (_, i) => (
58
+ <rect key={i} x={-800 + i * 240} y={suelo + 150} width={130} height={14} rx={7} fill="#E8C35A" opacity={0.7} />
59
+ ))}
60
+ </g>
61
+ );
62
+
63
+ /** Interior: pared con zoclo, piso de madera, ventana, cuadro y lámpara con su cono de luz. */
64
+ export const Cuarto: React.FC<Base & {pared?: string; piso?: string; noche?: boolean; ventana?: boolean; lampara?: boolean; cuadro?: boolean}> = ({ancho = 2160, suelo = 1500, t = 0, pared = '#2E5A4C', piso = '#7A4A2A', noche = true, ventana = true, lampara = true, cuadro = true}) => (
65
+ <g>
66
+ <rect x={-800} y={-800} width={ancho + 1600} height={suelo + 800} fill={pared} />
67
+ {/* papel tapiz: rombos sutiles */}
68
+ <g opacity={0.08} fill="#000">
69
+ {Array.from({length: 40 * 30}, (_, i) => {
70
+ const c = i % 40;
71
+ const f = Math.floor(i / 40);
72
+ return <path key={i} d={`M ${-800 + c * 110 + (f % 2) * 55} ${-800 + f * 80} l 18 26 l -18 26 l -18 -26 Z`} />;
73
+ })}
74
+ </g>
75
+ <rect x={-800} y={suelo - 140} width={ancho + 1600} height={140} fill="rgba(0,0,0,0.22)" />
76
+ <rect x={-800} y={suelo - 150} width={ancho + 1600} height={14} fill="rgba(255,255,255,0.12)" />
77
+ <rect x={-800} y={suelo} width={ancho + 1600} height={900} fill={piso} />
78
+ {Array.from({length: 30}, (_, i) => (
79
+ <rect key={i} x={-800} y={suelo + 30 + i * 46} width={ancho + 1600} height={3} fill="rgba(0,0,0,0.18)" />
80
+ ))}
81
+ {ventana ? (
82
+ <g transform={`translate(${ancho * 0.72} ${suelo - 1000})`}>
83
+ <rect x={-20} y={-20} width={420} height={520} rx={10} fill="#E9E1D2" />
84
+ <rect width={380} height={480} fill={noche ? '#1A2350' : '#9FD4F5'} />
85
+ {noche ? <circle cx={270} cy={120} r={46} fill="#FFF4D6" /> : <circle cx={270} cy={120} r={54} fill="#FFE27A" />}
86
+ <rect x={186} width={8} height={480} fill="#E9E1D2" />
87
+ <rect y={236} width={380} height={8} fill="#E9E1D2" />
88
+ </g>
89
+ ) : null}
90
+ {cuadro ? (
91
+ <g transform={`translate(${ancho * 0.3} ${suelo - 1050})`}>
92
+ <rect x={-16} y={-16} width={352} height={262} fill="#B8873A" />
93
+ <rect width={320} height={230} fill="#8FD0C4" />
94
+ <path d="M 0 230 L 90 120 L 170 190 L 240 110 L 320 230 Z" fill="#3E8A55" />
95
+ <circle cx={250} cy={60} r={26} fill="#F5D46A" />
96
+ </g>
97
+ ) : null}
98
+ {lampara ? (
99
+ <g transform={`translate(${ancho * 0.12} ${suelo})`}>
100
+ <ConoLuz id="cuarto-cono" x={0} y={-640} ancho={720} largo={700} fuerza={0.4 + 0.03 * Math.sin(t * 2)} />
101
+ <rect x={-6} y={-600} width={12} height={600} fill="#2A2420" />
102
+ <ellipse cx={0} cy={0} rx={70} ry={14} fill="#2A2420" />
103
+ <path d="M -90 -640 L 90 -640 L 60 -760 L -60 -760 Z" fill="#F2D49B" />
104
+ </g>
105
+ ) : null}
106
+ </g>
107
+ );
108
+
109
+ /** Campo abierto de día: cielo, sol, nubes que avanzan, montañas, árboles y pasto. */
110
+ export const Campo: React.FC<Base & {tarde?: boolean}> = ({ancho = 2160, suelo = 1500, t = 0, camara, semilla = 0, tarde = false}) => (
111
+ <g>
112
+ <Paralaje camara={camara} profundidad={0.9}>
113
+ <Cielo id="campo-cielo" colores={tarde ? ['#F7B267', '#F4845F', '#F27059'] : ['#7EC8F2', '#B6E2F7', '#E8F6FC']} />
114
+ <circle cx={ancho * 0.75} cy={380} r={110} fill={tarde ? '#FFE3A3' : '#FFF1A8'} />
115
+ {[0, 1, 2, 3].map((i) => {
116
+ const x = ((azarN(semilla + i) * (ancho + 800) + t * (18 + i * 6)) % (ancho + 1200)) - 600;
117
+ const y = 220 + azarN(semilla + i + 9) * 380;
118
+ return (
119
+ <g key={i} transform={`translate(${x} ${y}) scale(${0.8 + azarN(i) * 0.6})`} fill="#fff" opacity={0.92}>
120
+ <circle cx={0} cy={0} r={60} />
121
+ <circle cx={70} cy={-20} r={75} />
122
+ <circle cx={150} cy={0} r={55} />
123
+ <rect x={0} y={0} width={150} height={55} />
124
+ </g>
125
+ );
126
+ })}
127
+ </Paralaje>
128
+ <Paralaje camara={camara} profundidad={0.6}>
129
+ <Montanas base={suelo - 220} alto={500} semilla={semilla + 3} color={tarde ? '#B5656B' : '#8DB7C9'} />
130
+ </Paralaje>
131
+ <Paralaje camara={camara} profundidad={0.3}>
132
+ <Pinos base={suelo - 60} alto={420} ancho={190} n={18} semilla={semilla + 7} color={tarde ? '#4E5B3A' : '#3E8A55'} />
133
+ </Paralaje>
134
+ <Suelo y={suelo} colores={tarde ? ['#8DA05A', '#55623A'] : ['#7CC45A', '#3E8A3E']} id="campo-suelo" />
135
+ <Pasto y={suelo} t={t} n={180} color={tarde ? '#6E7F44' : '#5AA845'} semilla={semilla + 11} />
136
+ </g>
137
+ );
138
+
139
+ /** Calle de día con banqueta, casas al fondo y postes. */
140
+ export const Calle: React.FC<Base & {noche?: boolean}> = ({ancho = 2160, suelo = 1500, t = 0, camara, semilla = 0, noche = false}) => {
141
+ const casas = ['#F2E6D0', '#F7D9C4', '#DDE8F0', '#F4E3A1', '#E8D5F0'];
142
+ return (
143
+ <g>
144
+ <Paralaje camara={camara} profundidad={0.9}>
145
+ <Cielo id="calle-cielo" colores={noche ? ['#0B1033', '#232A63'] : ['#8FD0F5', '#DDF2FC']} />
146
+ </Paralaje>
147
+ <Paralaje camara={camara} profundidad={0.3}>
148
+ <g>
149
+ {Array.from({length: 8}, (_, i) => {
150
+ const x = -400 + i * ((ancho + 800) / 8);
151
+ const w = 300 + azarN(semilla + i) * 60;
152
+ const h = 320 + azarN(semilla + i + 3) * 140;
153
+ return (
154
+ <g key={i}>
155
+ <rect x={x} y={suelo - 120 - h} width={w} height={h} fill={casas[i % casas.length]} />
156
+ <path d={`M ${x - 20} ${suelo - 120 - h} L ${x + w / 2} ${suelo - 120 - h - 150} L ${x + w + 20} ${suelo - 120 - h} Z`} fill={['#D9533B', '#8A3B2A', '#4A5A7A'][i % 3]} />
157
+ <rect x={x + w * 0.4} y={suelo - 120 - 170} width={70} height={170} fill="#7A4E2D" />
158
+ <rect x={x + w * 0.1} y={suelo - 120 - h + 60} width={80} height={70} fill={noche ? '#FFD27A' : '#9AD4F5'} />
159
+ <rect x={x + w * 0.65} y={suelo - 120 - h + 60} width={80} height={70} fill={noche ? '#FFD27A' : '#9AD4F5'} />
160
+ </g>
161
+ );
162
+ })}
163
+ </g>
164
+ </Paralaje>
165
+ <rect x={-800} y={suelo - 120} width={ancho + 1600} height={120} fill="#C9C4BA" />
166
+ <rect x={-800} y={suelo - 124} width={ancho + 1600} height={10} fill="#E2DDD3" />
167
+ <rect x={-800} y={suelo} width={ancho + 1600} height={900} fill="#3E4250" />
168
+ {Array.from({length: Math.ceil((ancho + 1600) / 260)}, (_, i) => (
169
+ <rect key={i} x={-800 + i * 260 - ((t * 0) % 260)} y={suelo + 210} width={140} height={16} rx={8} fill="#F2D04A" />
170
+ ))}
171
+ {[0.15, 0.55, 0.92].map((f, i) => (
172
+ <g key={i} transform={`translate(${ancho * f} ${suelo - 120})`}>
173
+ <rect x={-8} y={-620} width={16} height={620} fill="#5A5F6B" />
174
+ <rect x={-8} y={-620} width={110} height={12} fill="#5A5F6B" />
175
+ <ellipse cx={100} cy={-602} rx={24} ry={10} fill={noche ? '#FFE9A8' : '#C9CED6'} />
176
+ </g>
177
+ ))}
178
+ </g>
179
+ );
180
+ };
181
+
182
+ /** Oficina: pared clara, escritorio con monitor, planta y repisa. */
183
+ export const Oficina: React.FC<Base> = ({ancho = 2160, suelo = 1500, t = 0}) => (
184
+ <g>
185
+ <rect x={-800} y={-800} width={ancho + 1600} height={suelo + 800} fill="#E8E4DC" />
186
+ <rect x={-800} y={suelo} width={ancho + 1600} height={900} fill="#B9A58A" />
187
+ <rect x={-800} y={suelo - 10} width={ancho + 1600} height={10} fill="#9C8A70" />
188
+ <g transform={`translate(${ancho * 0.62} ${suelo})`}>
189
+ <rect x={-360} y={-420} width={720} height={36} rx={8} fill="#8A5A3B" />
190
+ <rect x={-330} y={-384} width={30} height={384} fill="#6B4630" />
191
+ <rect x={300} y={-384} width={30} height={384} fill="#6B4630" />
192
+ <rect x={-150} y={-720} width={300} height={200} rx={14} fill="#22252E" />
193
+ <rect x={-136} y={-706} width={272} height={172} rx={8} fill="#3B7BE0" opacity={0.85 + 0.1 * Math.sin(t * 2)} />
194
+ <rect x={-20} y={-520} width={40} height={100} fill="#22252E" />
195
+ <rect x={-90} y={-430} width={180} height={14} rx={5} fill="#22252E" />
196
+ </g>
197
+ <g transform={`translate(${ancho * 0.2} ${suelo})`}>
198
+ <path d="M -70 0 L 70 0 L 55 -150 L -55 -150 Z" fill="#C9653E" />
199
+ {[-50, -20, 10, 40].map((x, i) => (
200
+ <ellipse key={i} cx={x} cy={-230 - (i % 2) * 40} rx={36} ry={90} fill="#3E8A55" transform={`rotate(${(i - 1.5) * 18} ${x} -160)`} />
201
+ ))}
202
+ </g>
203
+ <g transform={`translate(${ancho * 0.3} ${suelo - 1100})`}>
204
+ <rect x={-200} y={0} width={400} height={20} fill="#8A5A3B" />
205
+ {['#E04A4A', '#2F6FE0', '#F2B632', '#2FA36B'].map((c, i) => (
206
+ <rect key={i} x={-180 + i * 50} y={-130 + (i % 2) * 20} width={40} height={130 - (i % 2) * 20} fill={c} />
207
+ ))}
208
+ </g>
209
+ </g>
210
+ );
211
+
212
+ /** Mar: superficie con olas o fondo marino con rayos de luz y burbujas (`profundo`). */
213
+ export const Mar: React.FC<Base & {profundo?: boolean}> = ({ancho = 2160, suelo = 1500, t = 0, camara, semilla = 0, profundo = false}) => {
214
+ if (profundo)
215
+ return (
216
+ <g>
217
+ <Cielo id="mar-fondo" colores={['#0E5AA0', '#073A70', '#031A38']} />
218
+ <g style={{mixBlendMode: 'screen'}} opacity={0.18} filter="url(#kit-lejos)">
219
+ {Array.from({length: 9}, (_, i) => {
220
+ const x = -300 + i * ((ancho + 600) / 9) + Math.sin(t * 0.4 + i) * 40;
221
+ return <polygon key={i} points={`${x},-400 ${x + 90},-400 ${x + 260},${suelo + 200} ${x + 60},${suelo + 200}`} fill="#BFE3FF" />;
222
+ })}
223
+ </g>
224
+ {Array.from({length: 40}, (_, i) => {
225
+ const s = semilla + i;
226
+ const y = ((suelo + 300 - ((t * (40 + azarN(s) * 60) + azarN(s + 1) * 2000) % 2400)) as number);
227
+ return <circle key={i} cx={azarN(s + 2) * ancho + Math.sin(t + i) * 12} cy={y} r={3 + azarN(s + 3) * 7} fill="none" stroke="rgba(200,235,255,0.5)" strokeWidth={2} />;
228
+ })}
229
+ <path d={`M -800 ${suelo + 120} ${Array.from({length: 30}, (_, i) => `L ${-800 + i * 140} ${suelo + 100 + Math.sin(i * 1.3) * 30}`).join(' ')} L ${ancho + 800} ${suelo + 900} L -800 ${suelo + 900} Z`} fill="#0A2440" />
230
+ </g>
231
+ );
232
+ const ola = (y: number, amp: number, fase: number, color: string) => {
233
+ let d = `M -800 ${y}`;
234
+ for (let x = -800; x <= ancho + 800; x += 40) d += ` L ${x} ${y + Math.sin(x / 120 + t * 1.6 + fase) * amp}`;
235
+ return <path d={`${d} L ${ancho + 800} ${y + 1200} L -800 ${y + 1200} Z`} fill={color} />;
236
+ };
237
+ return (
238
+ <g>
239
+ <Paralaje camara={camara} profundidad={0.9}>
240
+ <Cielo id="mar-cielo" colores={['#7EC8F2', '#CDEBFA']} />
241
+ <circle cx={ancho * 0.25} cy={420} r={100} fill="#FFF1A8" />
242
+ </Paralaje>
243
+ {ola(suelo - 180, 10, 0, '#4FA7E0')}
244
+ {ola(suelo - 80, 16, 1.4, '#2E86C9')}
245
+ {ola(suelo + 40, 22, 2.6, '#1E6AA8')}
246
+ </g>
247
+ );
248
+ };
249
+
250
+ export {alturaSuelo};
@@ -1,7 +1,14 @@
1
1
  // Kits de Morfeo: piezas para armar las escenas a medida de un video dentro de tus `componentes`.
2
2
  // import {Cielo, Pinos, Paralaje} from 'morfeo/kit'; (2D ilustrado, dentro de un <svg>)
3
- // import {Lienzo3D, Terreno, Tubo, Mesa, Pila} from 'morfeo/kit'; (3D: maqueta de día o mesa con luz cálida)
3
+ // import {Persona2D, Multitud, Globo, CiudadNoche, Cuarto} from 'morfeo/kit'; (personajes y escenarios 2D)
4
+ // import {Lienzo3D, Persona3D, Ciudad, Terreno, Tubo, Mesa, Pila} from 'morfeo/kit'; (3D: de día o con luz cálida)
5
+ // import {Maqueta, Modelo, Persona, Coche, Trafico, Corte} from 'morfeo/kit'; (diorama low-poly)
6
+ // import {GraficaBarras, GraficaLinea, Comparacion, Lupa} from 'morfeo/kit'; (datos 2D)
4
7
  export * from './ilustrado';
8
+ export * from './personas';
9
+ export * from './escenarios2d';
5
10
  export * from './tres';
6
- export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';
11
+ export * from './mundo3d';
12
+ export * from './datos';
7
13
  export * from './diorama';
14
+ export {clamp, easeIO, easeOut, easeOutExpo, backOut, resorte, ruido, lerp, mezclaColor} from '../estilo';