morfeo 0.1.2 → 0.1.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/index.d.ts CHANGED
@@ -77,7 +77,7 @@ export type Capa = CapaBase &
77
77
  | {tipo: 'marca'; tam?: number}
78
78
  | {tipo: 'particulas'; particulas?: 'burbujas' | 'polvo' | 'estrellas' | 'nieve' | 'confeti'; cantidad?: number; color?: string}
79
79
  | {tipo: 'grupo'; capas?: Capa[]; capa?: Capa; repetir?: number; disposicion?: 'circulo' | 'fila' | 'columna' | 'cuadricula' | 'pila' | 'aleatorio'; radio?: number; espacio?: number; espacioV?: number; columnas?: number; orientar?: boolean | number; retraso?: number; area?: [number, number]; girarAzar?: number; desorden?: number}
80
- | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote' | Objeto3D; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number; /** tuberia */ puntos?: [number, number, number][]; radio?: number; material?: 'pvc' | 'cobre' | 'acero' | 'vidrio'; agua?: boolean; velocidad?: number; colorAgua?: string; gotas?: number; /** muro */ columnas?: number; filas?: number; colorLadrillo?: string; colorMezcla?: string; /** viga */ largo?: number; vertical?: boolean; /** escalera */ escalones?: number; /** suelo */ capas?: [string, number][]}
80
+ | {tipo: 'modelo'; src?: string; forma?: 'moneda' | 'billete' | 'cubo' | 'esfera' | 'lingote' | Objeto3D; color?: string; metal?: number; rugosidad?: number; ancho?: number; alto?: number; tamano?: number; rotacion?: [number, number, number]; girar3d?: [number, number, number]; cantidad?: number; crecer?: boolean; duracion?: number; distancia?: number; camaraY?: number; luz?: number; reflejos?: number; /** levanta hacia la cámara los modelos planos acostados (por defecto en recursos) */ levantar?: boolean; /** tuberia */ puntos?: [number, number, number][]; radio?: number; material?: 'pvc' | 'cobre' | 'acero' | 'vidrio'; agua?: boolean; velocidad?: number; colorAgua?: string; gotas?: number; /** muro */ columnas?: number; filas?: number; colorLadrillo?: string; colorMezcla?: string; /** viga */ largo?: number; vertical?: boolean; /** escalera */ escalones?: number; /** suelo */ capas?: [string, number][]}
81
81
  | {tipo: 'componente'; nombre: string; props?: Record<string, unknown>}
82
82
  | {tipo: 'personaje'; nombre: string; pose?: string; poses?: {en: Momento; pose: string}[]; hablar?: boolean; ancho?: number}
83
83
  | {tipo: 'escena3d'; src: string; camara?: {en?: Momento; pos: [number, number, number]; mirar?: [number, number, number]; fov?: number}[]; animacion?: boolean | string | string[]; velocidad?: number; mostrar?: {nodo: string; en: Momento}[]; ocultar?: {nodo: string; en: Momento}[]; fondo?: string | null; niebla?: [number, number]; luz?: number; luzAmbiente?: number; sol?: [number, number, number]; reflejos?: number; sombras?: boolean; ancho?: number; alto?: number}
@@ -90,7 +90,7 @@ export type Capa = CapaBase &
90
90
  | {tipo: 'onda'; barras?: number; ancho?: number; alto?: number; color?: string; siempre?: boolean}
91
91
  | {tipo: 'globo'; texto: string; cola?: 'abajo' | 'arriba' | 'izq' | 'der' | 'ninguna'; fondo?: string; color?: string; borde?: string; tam?: number; maxAncho?: number}
92
92
  | {tipo: 'seguir'; usuario?: string; texto?: string; pulsar?: number}
93
- | {tipo: 'recurso'; buscar: string; estilo?: '2d' | '3d' | 'realista'; variante?: 'color' | 'clay' | 'glass' | 'kawaii' | 'retro' | 'pastel' | 'sticker' | 'skeuo' | 'plush' | 'anime' | 'luxe' | 'chrome' | 'liquid' | 'bauhaus' | 'brutal' | 'gothic' | 'linea'; fuente?: 'kenney' | 'polyhaven' | 'iconify' | 'polypizza'; indice?: number; color?: string; ancho?: number; alto?: number; girar3d?: [number, number, number]; rotacion?: [number, number, number]}
93
+ | {tipo: 'recurso'; buscar: string; estilo?: '2d' | '3d' | 'realista'; variante?: 'color' | 'clay' | 'glass' | 'kawaii' | 'retro' | 'pastel' | 'sticker' | 'skeuo' | 'plush' | 'anime' | 'luxe' | 'chrome' | 'liquid' | 'bauhaus' | 'brutal' | 'gothic' | 'linea' | 'plano'; fuente?: 'kenney' | 'polyhaven' | 'iconify' | 'polypizza'; indice?: number; color?: string; ancho?: number; alto?: number; girar3d?: [number, number, number]; rotacion?: [number, number, number]}
94
94
  | {tipo: 'escenario'; nombre: Escenario; parte?: 'fondo' | 'frente'; [opcion: string]: unknown}
95
95
  | {tipo: 'ventana'; diametro?: number; src?: string; capas?: Capa[]; borde?: 'metal' | 'ninguno' | (string & {}); grosor?: number; remaches?: number | false; zoom?: number | [number, number]; fondo?: string}
96
96
  );
@@ -19,7 +19,7 @@ export const TEMAS = {
19
19
  casa: ['house', 'home'], cuarto: ['bedroom', 'room'], cama: ['bed'], sillon: ['sofa', 'couch', 'armchair'], sofa: ['sofa', 'couch'],
20
20
  silla: ['chair'], mesa: ['table'], escritorio: ['desk'], lampara: ['lamp'], librero: ['bookcase', 'shelf'], refrigerador: ['fridge', 'refrigerator'],
21
21
  estufa: ['stove', 'oven'], horno: ['oven'], lavabo: ['sink', 'bathroom sink'], fregadero: ['kitchen sink', 'sink'], inodoro: ['toilet'], banera: ['bathtub'],
22
- regadera: ['shower'], espejo: ['mirror'], puerta: ['door'], ventana: ['window'], televisor: ['television', 'tv'], planta: ['plant', 'potted plant'],
22
+ regadera: ['shower'], espejo: ['mirror'], puerta: ['door'], ventana: ['window'], televisor: ['television', 'tv', 'monitor'], television: ['television', 'tv', 'monitor'], tele: ['television', 'tv', 'monitor'], tv: ['tv', 'television', 'monitor'], planta: ['plant', 'potted plant'],
23
23
  alfombra: ['rug'], ropero: ['wardrobe', 'closet'], buro: ['nightstand'], cajon: ['drawer', 'cabinet'], lavadora: ['washer', 'washing machine'], microondas: ['microwave'], licuadora: ['blender'], ventilador: ['fan'], almohada: ['pillow'], cortina: ['curtain'], reloj: ['clock'], llave: ['key'], candado: ['padlock', 'lock'], cocina: ['kitchen'], bano: ['bathroom'],
24
24
  },
25
25
  construccion: {
@@ -69,7 +69,8 @@ export const TEMAS = {
69
69
  tecnologia: {
70
70
  computadora: ['computer', 'laptop'], laptop: ['laptop'], celular: ['phone', 'smartphone'], telefono: ['phone'], robot: ['robot'], chip: ['chip', 'microchip'],
71
71
  bateria: ['battery'], enchufe: ['plug', 'socket'], foco: ['light bulb', 'bulb'], camara: ['camera'], audifonos: ['headphones'], microfono: ['microphone'],
72
- control: ['gamepad', 'controller'], consola: ['console', 'game console'], antena: ['antenna'], satelite: ['satellite'], dron: ['drone'], servidor: ['server'],
72
+ control: ['gamepad', 'controller'], consola: ['game console', 'console', 'gamepad'], consola_portatil: ['handheld console', 'nintendo switch', 'tablet', 'gamepad'],
73
+ tablet: ['tablet'], monitor: ['monitor', 'screen'], pantalla: ['screen', 'monitor'], disco: ['disc', 'cd', 'compact disc'], cd: ['disc', 'cd', 'compact disc'], dvd: ['disc', 'dvd'], memoria_usb: ['usb', 'flash drive'], antena: ['antenna'], satelite: ['satellite'], dron: ['drone'], servidor: ['server'],
73
74
  wifi: ['wifi'], nube_de_datos: ['cloud computing', 'cloud'], codigo: ['code'], teclado: ['keyboard'], raton_pc: ['computer mouse'],
74
75
  },
75
76
  espacio: {
@@ -97,6 +98,8 @@ export const TEMAS = {
97
98
  juegos: {
98
99
  videojuego: ['video game', 'gamepad'], dado: ['dice'], carta: ['playing card', 'card'], ajedrez: ['chess'], moneda_juego: ['coin'], corona: ['crown'], espada: ['sword'],
99
100
  escudo: ['shield'], cofre_juego: ['chest'], gema: ['gem', 'crystal'], pocion: ['potion'], bandera: ['flag'], meta: ['finish line', 'flag'],
101
+ cartucho: ['game cartridge', 'cartridge', 'sd card', 'floppy disk'], joystick: ['joystick', 'gamepad'], arcade: ['arcade machine', 'arcade', 'gamepad'], jefe_final: ['monster', 'dragon', 'skull'],
102
+ vida: ['heart', 'life'], nivel: ['level', 'block', 'platform'], bloque: ['block', 'cube', 'brick'], llave_juego: ['key'], hongo: ['mushroom'], estrella_juego: ['star'],
100
103
  },
101
104
  fiestas: {
102
105
  regalo: ['gift', 'present'], globo: ['balloon'], pastel_cumpleanos: ['birthday cake', 'cake'], arbol_navidad: ['christmas tree'], nieve: ['snow', 'snowman'],
package/lib/recursos.mjs CHANGED
@@ -39,6 +39,8 @@ export const VARIANTES_2D = {
39
39
  brutal: ['withicons-brutal'],
40
40
  gothic: ['withicons-gothic'],
41
41
  linea: ['ph', 'mdi', 'iconoir', 'tabler', 'lucide'],
42
+ // ilustración plana con muchísimos animales y objetos: último respaldo 2D antes de pasar a 3D
43
+ plano: ['fluent-emoji-flat', 'noto'],
42
44
  };
43
45
 
44
46
  async function pedirJSON(url, opciones = {}) {
@@ -252,19 +254,26 @@ export async function resolverRecursos(video, assetsPublico, log = console.log)
252
254
  const {tipo, buscar, estilo = '3d', variante, fuente, indice: n = 0, color, ...resto} = c;
253
255
  if (!buscar) throw new ErrorMorfeo('Una capa {tipo: "recurso"} necesita `buscar` (por ejemplo, buscar: "tubería")');
254
256
  let res = await buscarRecursos(buscar, {estilo, variante, fuente});
255
- // sin resultados en ese estilo: se intenta en los otros antes de fallar (3d → realista → 2d)
256
- if (!res.length && !fuente)
257
- for (const otro of ['3d', 'realista', '2d'].filter((e) => e !== estilo)) {
258
- res = await buscarRecursos(buscar, {estilo: otro, variante});
257
+ // sin resultados: primero se prueba el mismo estilo con otra línea gráfica (2d glass → 2d color → otras
258
+ // variantes), y luego los otros estilos. Así un 2D no termina siendo un modelo realista si hay otro 2D.
259
+ if (!res.length && !fuente) {
260
+ const intentos =
261
+ estilo === '2d'
262
+ ? [...Object.keys(VARIANTES_2D).filter((v) => v !== (variante ?? 'color') && v !== 'linea').map((v) => ['2d', v]), ['3d'], ['realista']]
263
+ : [...['3d', 'realista'].filter((e) => e !== estilo).map((e) => [e]), ['2d', variante]];
264
+ for (const [otro, otraVariante] of intentos) {
265
+ res = await buscarRecursos(buscar, {estilo: otro, variante: otraVariante});
259
266
  if (res.length) {
260
- log(` "${buscar}" no está en ${estilo}; uso ${otro}`);
267
+ log(` "${buscar}" no está en ${estilo}${variante ? ' ' + variante : ''}; uso ${otro}${otraVariante && otro === '2d' ? ' ' + otraVariante : ''}`);
261
268
  break;
262
269
  }
263
270
  }
264
- if (!res.length)
265
- throw new ErrorMorfeo(
266
- `No encontré "${buscar}" (${estilo}${variante ? ' ' + variante : ''}). Prueba un sinónimo, escríbelo en inglés (buscar: 'barrel'), otro estilo ('2d', '3d', 'realista'), o mira npx morfeo recursos buscar "${buscar}"`,
267
- );
271
+ }
272
+ // nada en ninguna biblioteca: se avisa y la capa se omite (el video se hace igual)
273
+ if (!res.length) {
274
+ log(` ⚠ No encontré "${buscar}" en ninguna biblioteca; omito esa capa. Prueba un sinónimo o escríbelo en inglés: npx morfeo recursos buscar "${buscar}"`);
275
+ return null;
276
+ }
268
277
  const r = res[Math.min(n, res.length - 1)];
269
278
  const d = await bajarRecurso(r, {color});
270
279
  creditos.add(d.credito);
@@ -281,16 +290,18 @@ export async function resolverRecursos(video, assetsPublico, log = console.log)
281
290
  log(` recurso "${buscar}" → ${r.nombre} (${r.fuente})`);
282
291
  return r.tipo === 'modelo'
283
292
  ? // modelos externos: cámara más cerca y menos luz ambiente para que no se vean chicos ni deslavados
284
- {tipo: 'modelo', src, ancho: 620, alto: 620, girar3d: [0, 30, 0], distancia: 4.3, camaraY: 0.9, luzAmbiente: 0.25, reflejos: 0.45, luz: 1.6, ...resto}
293
+ {tipo: 'modelo', src, ancho: 620, alto: 620, girar3d: [0, 30, 0], levantar: true, distancia: 4.3, camaraY: 0.9, luzAmbiente: 0.25, reflejos: 0.45, luz: 1.6, ...resto}
285
294
  : {tipo: 'imagen', src, ancho: 460, sombra: true, ...resto};
286
295
  void base;
287
296
  };
288
297
  const recorrer = async (x) => {
289
- if (Array.isArray(x)) return Promise.all(x.map(recorrer));
298
+ if (Array.isArray(x)) return (await Promise.all(x.map(recorrer))).filter((v) => v !== null);
290
299
  if (!x || typeof x !== 'object') return x;
291
300
  if (x.tipo === 'recurso') return resolver(x);
292
301
  const out = {};
293
302
  for (const [k, v] of Object.entries(x)) out[k] = await recorrer(v);
303
+ // un grupo cuya capa repetida no se encontró tampoco se dibuja
304
+ if (x.tipo === 'grupo' && x.capa && out.capa === null) return null;
294
305
  return out;
295
306
  };
296
307
  const scenes = await recorrer(video.scenes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morfeo",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Genera videos verticales animados (Remotion + Three.js) con voz, música y subtítulos, desde un guion en código o escrito por IA.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -25,7 +25,41 @@ const cargarGltf = (url: string) => {
25
25
  };
26
26
  const cargar = (url: string) => cargarGltf(url).then((g) => g.scene);
27
27
 
28
- const Glb: React.FC<{src: string}> = ({src}) => {
28
+ /**
29
+ * Caja del "cuerpo" del modelo: si casi todos los vértices se juntan en una zona pequeña y el resto son
30
+ * hilos sueltos (el cable de un control, la cuerda de un globo), encuadra esa zona y no la caja completa.
31
+ */
32
+ function cajaPrincipal(obj: THREE.Object3D): THREE.Box3 {
33
+ const completa = new THREE.Box3().setFromObject(obj);
34
+ const pts: number[] = [];
35
+ const v = new THREE.Vector3();
36
+ obj.updateMatrixWorld(true);
37
+ obj.traverse((o) => {
38
+ const m = o as THREE.Mesh;
39
+ const pos = m.isMesh ? (m.geometry?.attributes?.position as THREE.BufferAttribute | undefined) : undefined;
40
+ if (!pos) return;
41
+ const paso = Math.max(1, Math.floor(pos.count / 20000));
42
+ for (let i = 0; i < pos.count; i += paso) {
43
+ v.fromBufferAttribute(pos, i).applyMatrix4(m.matrixWorld);
44
+ pts.push(v.x, v.y, v.z);
45
+ }
46
+ });
47
+ if (pts.length < 300) return completa;
48
+ const N = 10;
49
+ const mn = completa.min.toArray();
50
+ const tam = completa.getSize(new THREE.Vector3()).toArray();
51
+ const celda = (i: number) => [0, 1, 2].map((k) => Math.min(N - 1, Math.floor(((pts[i + k] - mn[k]) / (tam[k] || 1)) * N))).join();
52
+ const cuenta = new Map<string, number>();
53
+ for (let i = 0; i < pts.length; i += 3) cuenta.set(celda(i), (cuenta.get(celda(i)) ?? 0) + 1);
54
+ const max = Math.max(...cuenta.values());
55
+ const caja = new THREE.Box3();
56
+ for (let i = 0; i < pts.length; i += 3) if ((cuenta.get(celda(i)) ?? 0) >= max * 0.2) caja.expandByPoint(v.set(pts[i], pts[i + 1], pts[i + 2]));
57
+ const lado = (b: THREE.Box3) => Math.max(...b.getSize(new THREE.Vector3()).toArray());
58
+ // solo si el cuerpo es mucho más chico que todo: en modelos normales la caja densa es casi la completa
59
+ return lado(caja) < 0.5 * lado(completa) ? caja.expandByScalar(lado(caja) * 0.08) : completa;
60
+ }
61
+
62
+ const Glb: React.FC<{src: string; levantar?: boolean}> = ({src, levantar}) => {
29
63
  const url = recurso(src);
30
64
  const [escena, setEscena] = useState<THREE.Group | null>(null);
31
65
  const [handle] = useState(() => delayRender(`Cargando modelo ${src}`));
@@ -34,20 +68,28 @@ const Glb: React.FC<{src: string}> = ({src}) => {
34
68
  .then((s) => {
35
69
  // centrado y escalado a ~2 unidades para que cualquier modelo quepa en el lienzo
36
70
  const copia = s.clone(true);
37
- const caja = new THREE.Box3().setFromObject(copia);
71
+ const caja = levantar ? cajaPrincipal(copia) : new THREE.Box3().setFromObject(copia);
38
72
  const tam = caja.getSize(new THREE.Vector3());
39
73
  const centro = caja.getCenter(new THREE.Vector3());
40
74
  const k = 2 / Math.max(tam.x, tam.y, tam.z, 0.0001);
41
75
  copia.position.sub(centro.multiplyScalar(k));
42
76
  copia.scale.setScalar(k);
43
- setEscena(copia);
77
+ // objetos planos acostados (un control, un libro, un plato): de frente se ven como una raya,
78
+ // así que se levantan hacia la cámara
79
+ const plano = levantar && tam.y < 0.3 * Math.max(tam.x, tam.z);
80
+ if (plano) {
81
+ const g = new THREE.Group();
82
+ g.add(copia);
83
+ g.rotation.x = 65 * RAD;
84
+ setEscena(g);
85
+ } else setEscena(copia);
44
86
  continueRender(handle);
45
87
  })
46
88
  .catch((e) => {
47
89
  continueRender(handle);
48
90
  throw new Error(`No pude cargar el modelo 3D ${src}: ${e.message}`);
49
91
  });
50
- }, [url, handle, src]);
92
+ }, [url, handle, src, levantar]);
51
93
  return escena ? <primitive object={escena} /> : null;
52
94
  };
53
95
 
@@ -137,7 +179,7 @@ export const Modelo3D: React.FC<{c: Record<string, any>; ctx: CtxEscena}> = ({c,
137
179
  <directionalLight position={[-4, 2, -3]} intensity={0.4} color="#9fc4ff" />
138
180
  <group scale={c.tamano ?? 1} rotation={[rx + gx * t, ry + gy * t, rz + gz * t]} position={[0, n > 1 ? -altoPila / 2 : 0, 0]}>
139
181
  {c.src ? (
140
- <Glb src={c.src} />
182
+ <Glb src={c.src} levantar={c.levantar} />
141
183
  ) : (OBJETOS_3D as readonly string[]).includes(c.forma) ? (
142
184
  <Objeto3D forma={c.forma} t={t} opciones={c} color={c.color} metal={c.metal} rugosidad={c.rugosidad} />
143
185
  ) : (
@@ -80,11 +80,11 @@ export const Subtitulos: React.FC<{tema: Tema; palabras: Palabra[]; t: number; d
80
80
 
81
81
  if (S.tipo === 'pop') {
82
82
  return (
83
- <div style={{position: 'absolute', left: 60, width: W - 120, top: S.y, transform: `translateY(-50%) scale(${backOut(entrada)})`, display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: '0 22px'}}>
83
+ <div style={{position: 'absolute', left: 60, width: W - 120, top: S.y, transform: `translateY(-50%) scale(${backOut(entrada)})`, display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: `0 ${Math.round(S.tam * 1.5 * 0.4 + 20)}px`}}>
84
84
  {g.map(([w, a, b], i) => {
85
85
  const activa = t >= a && t < b + 0.05;
86
86
  return (
87
- <span key={i} style={{fontFamily: tema.fuentes.titulo, fontWeight: 900, fontSize: S.tam * 1.5, color: activa ? tema.colores.acento : S.color, WebkitTextStroke: '10px #000', paintOrder: 'stroke fill', textTransform: 'uppercase', transform: `scale(${activa ? 1.1 : 1})`}}>
87
+ <span key={i} style={{fontFamily: tema.fuentes.titulo, fontWeight: 900, fontSize: S.tam * 1.5, color: activa ? tema.colores.acento : S.color, WebkitTextStroke: '10px #000', paintOrder: 'stroke fill', textTransform: 'uppercase', display: 'inline-block', transformOrigin: 'center bottom', transform: `scale(${activa ? 1.1 : 1})`}}>
88
88
  {w}
89
89
  </span>
90
90
  );