morfeo 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +210 -2
- package/assets/fuentes/Anton.ttf +0 -0
- package/assets/fuentes/JetBrainsMono.ttf +0 -0
- package/assets/fuentes/Montserrat.ttf +0 -0
- package/bin/morfeo.mjs +184 -0
- package/bin/postinstall.mjs +10 -0
- package/index.d.ts +250 -0
- package/lib/config.mjs +284 -0
- package/lib/entorno.mjs +93 -0
- package/lib/guion.mjs +313 -0
- package/lib/imagenes.mjs +99 -0
- package/lib/index.mjs +15 -0
- package/lib/instalar.mjs +193 -0
- package/lib/navegador.mjs +64 -0
- package/lib/personajes.mjs +94 -0
- package/lib/render.mjs +312 -0
- package/lib/voces.mjs +122 -0
- package/lib/whisper.mjs +45 -0
- package/package.json +84 -5
- package/plantilla/assets/.gitkeep +0 -0
- package/plantilla/env.example +7 -0
- package/plantilla/escenas/Barras.tsx +15 -0
- package/plantilla/gitignore +4 -0
- package/plantilla/guiones/cachalote.mjs +24 -0
- package/plantilla/guiones/tutorial.mjs +16 -0
- package/plantilla/morfeo.config.mjs +43 -0
- package/plantilla/package.json +13 -0
- package/plantilla/videos.mjs +25 -0
- package/python/musica.py +221 -0
- package/python/preparar.py +268 -0
- package/python/requirements.txt +5 -0
- package/remotion/Root.tsx +35 -0
- package/remotion/Short.tsx +278 -0
- package/remotion/escenas.tsx +430 -0
- package/remotion/estilo.ts +73 -0
- package/remotion/iconos.ts +326 -0
- package/remotion/index.ts +4 -0
- package/remotion/motor/Video.tsx +121 -0
- package/remotion/motor/capas.tsx +622 -0
- package/remotion/motor/fondo.tsx +148 -0
- package/remotion/motor/modelo3d.tsx +252 -0
- package/remotion/motor/plantillas.tsx +125 -0
- package/remotion/motor/registro.ts +11 -0
- package/remotion/motor/subtitulos.tsx +137 -0
- package/remotion/motor/tema.ts +207 -0
- package/remotion/motor/util.ts +66 -0
- package/remotion/motor/widgets.tsx +248 -0
- package/remotion/piezas.tsx +181 -0
- package/remotion/tipos.ts +43 -0
- package/remotion/tres/Capa3D.tsx +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "morfeo",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"author": "Uriel Gómez",
|
|
8
|
+
"homepage": "https://ureckchan.github.io/morfeo/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/UreckChan/morfeo.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/UreckChan/morfeo/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"video",
|
|
18
|
+
"shorts",
|
|
19
|
+
"remotion",
|
|
20
|
+
"threejs",
|
|
21
|
+
"tts",
|
|
22
|
+
"kokoro",
|
|
23
|
+
"elevenlabs",
|
|
24
|
+
"subtitulos",
|
|
25
|
+
"animacion"
|
|
26
|
+
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"morfeo": "bin/morfeo.mjs"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./index.d.ts",
|
|
33
|
+
"default": "./lib/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json",
|
|
36
|
+
"./remotion/*": "./remotion/*"
|
|
37
|
+
},
|
|
38
|
+
"types": "./index.d.ts",
|
|
39
|
+
"files": [
|
|
40
|
+
"bin",
|
|
41
|
+
"lib",
|
|
42
|
+
"remotion",
|
|
43
|
+
"python/*.py",
|
|
44
|
+
"python/requirements.txt",
|
|
45
|
+
"assets",
|
|
46
|
+
"plantilla",
|
|
47
|
+
"index.d.ts",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18.17"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"postinstall": "node bin/postinstall.mjs",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "node --test test/",
|
|
58
|
+
"prepublishOnly": "tsc --noEmit && node bin/morfeo.mjs -v"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@anthropic-ai/sdk": "^1.6.0",
|
|
62
|
+
"@react-three/fiber": "^9.3.0",
|
|
63
|
+
"@remotion/bundler": "4.0.541",
|
|
64
|
+
"@remotion/cli": "4.0.541",
|
|
65
|
+
"@remotion/fonts": "4.0.541",
|
|
66
|
+
"@remotion/install-whisper-cpp": "4.0.541",
|
|
67
|
+
"@remotion/layout-utils": "4.0.541",
|
|
68
|
+
"@remotion/noise": "4.0.541",
|
|
69
|
+
"@remotion/renderer": "4.0.541",
|
|
70
|
+
"@remotion/three": "4.0.541",
|
|
71
|
+
"react": "^19.2.0",
|
|
72
|
+
"react-dom": "^19.2.0",
|
|
73
|
+
"remotion": "4.0.541",
|
|
74
|
+
"three": "^0.178.0",
|
|
75
|
+
"zod": "^4.1.0"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@types/react": "^19.2.2",
|
|
79
|
+
"@types/three": "^0.178.1",
|
|
80
|
+
"typescript": "^5.9.3"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"access": "public"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copia este archivo como .env y llena solo lo que vayas a usar. Nunca lo subas a git.
|
|
2
|
+
# Guiones con IA (npx morfeo guion "tema"):
|
|
3
|
+
ANTHROPIC_API_KEY=
|
|
4
|
+
# Voces de ElevenLabs ({proveedor: 'elevenlabs', voz: '<voice_id>'}):
|
|
5
|
+
ELEVENLABS_API_KEY=
|
|
6
|
+
# Voces de OpenAI ({proveedor: 'openai', voz: 'nova'}):
|
|
7
|
+
OPENAI_API_KEY=
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Un componente propio en React: úsalo con {tipo: 'componente', nombre: 'Barras', props: {valores: [2, 5, 13]}}.
|
|
2
|
+
// Recibe t (segundos desde que aparece), dur, palabras, tema y cuando('palabra') -> segundos.
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
export default function Barras({t, valores = [3, 7, 12], tema}: {t: number; valores?: number[]; tema: any}) {
|
|
6
|
+
const max = Math.max(...valores);
|
|
7
|
+
return (
|
|
8
|
+
<div style={{display: 'flex', alignItems: 'flex-end', gap: 40, height: 520}}>
|
|
9
|
+
{valores.map((v, i) => {
|
|
10
|
+
const k = Math.min(1, Math.max(0, (t - i * 0.25) / 0.6));
|
|
11
|
+
return <div key={i} style={{width: 150, height: (v / max) * 500 * k, background: i === valores.length - 1 ? tema.colores.acento : tema.colores.suave, borderRadius: 16}} />;
|
|
12
|
+
})}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Ejemplo del motor de capas, estilo "documental". Cada escena es una lista de capas animadas.
|
|
2
|
+
// Las palabras en `en` hacen que la capa aparezca justo cuando la voz las dice.
|
|
3
|
+
export default {
|
|
4
|
+
estilo: 'documental',
|
|
5
|
+
title: 'El cachalote, el gigante del océano 🐋',
|
|
6
|
+
voice: 'narrador',
|
|
7
|
+
music: 'ballad',
|
|
8
|
+
scenes: [
|
|
9
|
+
{say: '¿Sabías que las orcas le tienen miedo a un solo animal?', capas: [
|
|
10
|
+
// en tu canal real usa tus ilustraciones: {tipo: 'imagen', src: 'cachalote.png'} (carpeta assets/)
|
|
11
|
+
// o pídela a la IA: {tipo: 'imagen', generar: 'un cachalote, ilustración plana'}
|
|
12
|
+
{tipo: 'emoji', texto: '🐋', tam: 520, x: 540, y: 1000, entrada: 'deslizar-der', mover: {a: [480, 980]}, flotar: 18},
|
|
13
|
+
{tipo: 'chip', texto: '×5', x: 760, y: 1260, en: 'animal', entrada: 'rebote', sonido: 'pop'}]},
|
|
14
|
+
{type: 'dato', say: 'El cachalote puede bajar a más de dos mil metros.', show: 'El cachalote puede bajar a más de 2.000 metros.',
|
|
15
|
+
valor: 2000, sufijo: ' m', formato: 'chip', tam: 110, camara: {zoom: [1.15, 1]},
|
|
16
|
+
capas: [{tipo: 'emoji', texto: '🐋', tam: 300, x: 540, y: 620, rotar: 70, mover: {a: [540, 1500]}}]},
|
|
17
|
+
{say: 'Y forman un círculo para proteger a sus crías.', capas: [
|
|
18
|
+
{tipo: 'grupo', x: 540, y: 1000, repetir: 8, disposicion: 'circulo', radio: 300, orientar: 90, retraso: 0.08, girar: 6, capa: {tipo: 'emoji', texto: '🐋', tam: 150}},
|
|
19
|
+
{tipo: 'emoji', texto: '🐳', tam: 140, x: 540, y: 1000, en: 'crías', entrada: 'rebote'}]},
|
|
20
|
+
{say: 'Su sonido llega a doscientos treinta decibeles.', show: 'Su sonido llega a 230 decibeles.', capas: [
|
|
21
|
+
{tipo: 'contador', de: 0, a: 230, sufijo: ' dB', formato: 'lcd', x: 540, y: 980, en: 'sonido', tam: 120}]},
|
|
22
|
+
{type: 'cta'},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Ejemplo del estilo "explicativo": titulares, tarjetas que se palomean, comparación y un contador.
|
|
2
|
+
export default {
|
|
3
|
+
estilo: 'explicativo',
|
|
4
|
+
title: 'Haz videos con código 🎬',
|
|
5
|
+
voice: 'narrador',
|
|
6
|
+
music: 'chillpop',
|
|
7
|
+
scenes: [
|
|
8
|
+
{type: 'titular', say: 'Paso uno: instala Morfeo en tu computadora.', etiqueta: '// paso 1 · instala', texto: 'INSTALA *MORFEO*', numero: 1,
|
|
9
|
+
capas: [{tipo: 'tarjeta', cabecera: '// terminal', titulo: 'npx morfeo instalar', x: 540, y: 1080, ancho: 820, en: 'morfeo', sonido: 'pop'}]},
|
|
10
|
+
{type: 'lista', say: 'Morfeo pone la voz, la música y los subtítulos por ti.', titulo: 'Lo hace *solo*', cabecera: '// paso 2',
|
|
11
|
+
items: [{texto: 'Voz', check: 'voz'}, {texto: 'Música', check: 'música'}, {texto: 'Subtítulos', check: 'subtítulos'}]},
|
|
12
|
+
{type: 'comparar', say: 'Tu guion pasa de aburrido a viral.', a: 'ABURRIDO', b: '¡VIRAL!', at: 'viral'},
|
|
13
|
+
{type: 'dato', say: 'Un video al día puede llegar a más de un millón de visitas.', de: 100, valor: 1284930, curva: 'exponencial', texto: 'visitas', tam: 170},
|
|
14
|
+
{type: 'cta'},
|
|
15
|
+
],
|
|
16
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Configuración de tu canal. Los videos están en videos.mjs y en la carpeta guiones/.
|
|
2
|
+
import {defineConfig} from 'morfeo';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
// Estilo visual por defecto (cada video puede cambiarlo con estilo: '...'):
|
|
6
|
+
// datos-curiosos, explicativo, documental, ilustrado, historia, mesa, serie, infografia, 3d, minimal (npx morfeo estilos)
|
|
7
|
+
estilo: 'datos-curiosos',
|
|
8
|
+
|
|
9
|
+
marca: {
|
|
10
|
+
nombre: 'Mi Canal', // sale arriba en cada video y en el cierre
|
|
11
|
+
logo: null, // './logo.png' (cuadrado). Sin logo se usa la inicial del nombre
|
|
12
|
+
redes: 'YT @micanal · TikTok @micanal',
|
|
13
|
+
cta: 'Sígueme para más datos curiosos.', // lo que dice la voz en la escena final ({type: 'cta'})
|
|
14
|
+
etiqueta: 'DATO CURIOSO', // la pastilla roja del gancho
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
// Alias para no repetir: úsalos como voice: 'narrador' o music: 'mi-cancion'
|
|
18
|
+
voces: {
|
|
19
|
+
narrador: [['em_alex', 0.6], ['am_michael', 0.4]], // mezcla de voces Kokoro (gratis, en tu computadora)
|
|
20
|
+
// eleven: {proveedor: 'elevenlabs', voz: 'TU_VOICE_ID'}, // necesita ELEVENLABS_API_KEY en .env
|
|
21
|
+
// openai: {proveedor: 'openai', voz: 'nova'}, // necesita OPENAI_API_KEY en .env
|
|
22
|
+
},
|
|
23
|
+
musicas: {
|
|
24
|
+
// 'mi-cancion': {archivo: './musica/cancion.mp3', volumen: 0.8},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
// Tus propios estilos, heredando de uno existente:
|
|
28
|
+
// estilos: {'mi-marca': {base: 'minimal', colores: {acento: '#00E0A0'}, subtitulos: {resalte: '#00E0A0'}}},
|
|
29
|
+
|
|
30
|
+
// Imágenes con IA para capas {tipo: 'imagen', generar: '...'} (necesita OPENAI_API_KEY en .env):
|
|
31
|
+
// imagenes: {proveedor: 'openai', estilo: 'ilustración plana, colores cálidos, sin texto'},
|
|
32
|
+
|
|
33
|
+
// Componentes propios en React para capas {tipo: 'componente', nombre: 'Barras'}:
|
|
34
|
+
componentes: {Barras: './escenas/Barras.tsx'},
|
|
35
|
+
|
|
36
|
+
// Personajes con poses (series): {tipo: 'personaje', nombre: 'neto', pose: 'feliz'}
|
|
37
|
+
// personajes: {neto: {referencia: './personajes/neto.png', poses: {normal: './personajes/neto.png', triste: 'llorando, con lágrimas'}}},
|
|
38
|
+
|
|
39
|
+
// Fuentes propias: {'Poppins': './fuentes/Poppins-Bold.ttf'} y úsalas en tema: {fuentes: {titulo: 'Poppins'}}
|
|
40
|
+
// fuentes: {},
|
|
41
|
+
|
|
42
|
+
salida: './salida', // aquí quedan los .mp4 y .jpg
|
|
43
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Tus videos: {id: video}. Cada escena dice algo ("say") y lo ilustra en pantalla.
|
|
2
|
+
// Tipos: title, year, text, icon, compare, card, dialog, cta (y aliens, memory, morph).
|
|
3
|
+
// Colores: '#RRGGBB' o [r, g, b]. Íconos: question, warning, star, zap, eye, book, trophy...
|
|
4
|
+
import {defineVideos} from 'morfeo';
|
|
5
|
+
|
|
6
|
+
const B = '#FFFFFF'; // blanco
|
|
7
|
+
const A = '#FFD400'; // amarillo
|
|
8
|
+
const R = '#FF4050'; // rojo
|
|
9
|
+
const C = '#50DCFF'; // cian
|
|
10
|
+
|
|
11
|
+
export default defineVideos({
|
|
12
|
+
cielo: {
|
|
13
|
+
title: '¿Por qué el cielo es azul? 🌤️ | Dato curioso',
|
|
14
|
+
voice: 'narrador', // alias de morfeo.config.mjs (o directo: 'em_alex')
|
|
15
|
+
music: 'lofi', // lofi, lofi2, chillpop, synth, house, ballad, trap, un archivo './musica/x.mp3' o false
|
|
16
|
+
scenes: [
|
|
17
|
+
{type: 'title', say: '¿Sabías por qué el cielo es azul?', rows: [['¿POR QUÉ', B, 150], ['EL CIELO ES', B, 130], ['AZUL?', C, 250]], icon: 'question'},
|
|
18
|
+
{type: 'text', say: 'La luz del sol trae todos los colores mezclados.', rows: [['LA LUZ TRAE', B, 140], ['TODOS LOS', B, 140], ['COLORES', A, 210]]},
|
|
19
|
+
{type: 'icon', say: 'Pero al chocar con el aire, el azul rebota mucho más que el rojo.', icon: 'zap', label: 'EL AZUL REBOTA', color: C},
|
|
20
|
+
{type: 'compare', say: 'Por eso de día vemos azul, y al atardecer, naranja.', a: ['DE DÍA', C], b: ['ATARDECER', R], at: 'atardecer', sizes: [220, 180]},
|
|
21
|
+
{type: 'year', say: 'Lord Rayleigh lo explicó en mil ochocientos setenta y uno.', show: 'Lord Rayleigh lo explicó en 1871.', pre: 'RAYLEIGH', year: 1871, sub: 'DISPERSIÓN'},
|
|
22
|
+
{type: 'cta'}, // dice marca.cta
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
});
|
package/python/musica.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import os, numpy as np, soundfile as sf
|
|
2
|
+
from scipy.signal import butter, sosfilt, resample_poly
|
|
3
|
+
|
|
4
|
+
SR = 44100
|
|
5
|
+
def nf(m): return 440 * 2 ** ((m - 69) / 12)
|
|
6
|
+
def lp(x, fc, o=2): return sosfilt(butter(o, fc, "low", fs=SR, output="sos"), x)
|
|
7
|
+
def hp(x, fc, o=2): return sosfilt(butter(o, fc, "high", fs=SR, output="sos"), x)
|
|
8
|
+
def add(buf, x, t):
|
|
9
|
+
i = int(t * SR)
|
|
10
|
+
if i < 0 or i >= len(buf): return
|
|
11
|
+
x = x[: len(buf) - i]; buf[i:i + len(x)] += x
|
|
12
|
+
def dec(n, r, a=0.004):
|
|
13
|
+
e = np.exp(-np.arange(n) / (r * SR)); na = max(1, int(a * SR)); e[:na] *= np.linspace(0, 1, na); return e
|
|
14
|
+
|
|
15
|
+
# ---- instruments ----
|
|
16
|
+
def epiano(f, d):
|
|
17
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
18
|
+
mod = 1.6 * np.exp(-t * 6) * np.sin(2 * np.pi * f * t)
|
|
19
|
+
x = np.sin(2 * np.pi * f * t + mod) + 0.25 * np.sin(2 * np.pi * 2 * f * t) * np.exp(-t * 4)
|
|
20
|
+
return x * dec(n, 0.9, 0.006) * (1 + 0.08 * np.sin(2 * np.pi * 4.5 * t))
|
|
21
|
+
def pluck(f, d):
|
|
22
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
23
|
+
x = sum(np.sin(2 * np.pi * f * k * t) * np.exp(-t * (5 + 4 * k)) / k for k in range(1, 6))
|
|
24
|
+
return x * dec(n, 0.5, 0.002)
|
|
25
|
+
def bell(f, d):
|
|
26
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
27
|
+
x = np.sin(2 * np.pi * f * t + 2.2 * np.exp(-t * 3) * np.sin(2 * np.pi * f * 3.5 * t))
|
|
28
|
+
return x * dec(n, 0.6, 0.002)
|
|
29
|
+
def pad(freqs, d):
|
|
30
|
+
n = int(d * SR); t = np.arange(n) / SR; x = np.zeros(n)
|
|
31
|
+
for f in freqs:
|
|
32
|
+
for det in (-0.08, 0, 0.08):
|
|
33
|
+
ph = (t * f * 2 ** (det / 12)) % 1; x += 2 * ph - 1
|
|
34
|
+
x = lp(x, 1400) / (len(freqs) * 3)
|
|
35
|
+
e = np.minimum(1, t / 0.4) * np.minimum(1, (d - t) / 0.3); return x * np.clip(e, 0, 1)
|
|
36
|
+
def sub(f, d, glide=None):
|
|
37
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
38
|
+
ff = f if glide is None else f * (glide / f) ** np.clip(t / 0.08, 0, 1)
|
|
39
|
+
return np.sin(2 * np.pi * np.cumsum(np.ones(n) * ff) / SR) * dec(n, d * 0.6, 0.005)
|
|
40
|
+
def kick(g=1.0, d=0.35):
|
|
41
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
42
|
+
return g * np.sin(2 * np.pi * (48 + 70 * np.exp(-t * 28)) * t) * np.exp(-t * 9)
|
|
43
|
+
def snare(rng, d=0.2, tone=180):
|
|
44
|
+
n = int(d * SR); t = np.arange(n) / SR
|
|
45
|
+
return (lp(rng.standard_normal(n), 5000) * 0.6 + 0.4 * np.sin(2 * np.pi * tone * t)) * np.exp(-t * 18)
|
|
46
|
+
def clap(rng):
|
|
47
|
+
n = int(0.18 * SR); x = hp(rng.standard_normal(n), 900) * np.exp(-np.arange(n) / (0.05 * SR))
|
|
48
|
+
for o in (0.008, 0.016): x[int(o * SR):] += 0.6 * x[: n - int(o * SR)]
|
|
49
|
+
return x * 0.5
|
|
50
|
+
def hat(rng, d=0.05):
|
|
51
|
+
n = int(d * SR); return hp(rng.standard_normal(n), 7000) * dec(n, d / 3, 0.001)
|
|
52
|
+
|
|
53
|
+
STYLES = {
|
|
54
|
+
"lofi": dict(bpm=84, prog=[(53, [65, 69, 72, 76]), (52, [64, 67, 71, 74]), (50, [62, 65, 69, 72]), (48, [60, 64, 67, 71])], drum="lofi", lead="epiano", vinyl=True),
|
|
55
|
+
"lofi2": dict(bpm=78, prog=[(51, [63, 67, 70, 74]), (50, [62, 65, 69, 72]), (48, [60, 63, 67, 70]), (46, [58, 62, 65, 69])], drum="lofi", lead="pluckmel", vinyl=True),
|
|
56
|
+
"chillpop": dict(bpm=102, prog=[(48, [60, 64, 67]), (43, [62, 67, 71]), (45, [60, 64, 69]), (41, [60, 65, 69])], drum="pop", lead="arp8"),
|
|
57
|
+
"synth": dict(bpm=108, prog=[(45, [57, 60, 64]), (41, [57, 60, 65]), (48, [55, 60, 64]), (43, [55, 59, 62])], drum="four", lead="arp16", padon=True),
|
|
58
|
+
"house": dict(bpm=118, prog=[(50, [62, 65, 69, 72]), (46, [58, 62, 65, 69]), (53, [60, 65, 69, 72]), (48, [60, 64, 67, 71])], drum="house", lead="offchord", padon=True),
|
|
59
|
+
"ballad": dict(bpm=74, prog=[(48, [60, 64, 67, 71]), (45, [60, 64, 69, 72]), (41, [60, 65, 69, 72]), (43, [59, 62, 67, 71])], drum="soft", lead="pianoarp"),
|
|
60
|
+
"trap": dict(bpm=140, prog=[(48, [60, 63, 67]), (44, [60, 63, 68]), (51, [58, 63, 67]), (46, [58, 62, 65])], drum="trap", lead="bellmel"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def make_music(style, dur, seed):
|
|
64
|
+
S = dict(STYLES[style]); rng = np.random.default_rng(seed); N = int((dur + 2) * SR)
|
|
65
|
+
tr = (seed % 7) - 3
|
|
66
|
+
S["prog"] = [(r + tr, [c + tr for c in ch]) for r, ch in S["prog"]]
|
|
67
|
+
if seed % 2: S["prog"] = S["prog"][2:] + S["prog"][:2]
|
|
68
|
+
m = np.zeros(N); beat = 60 / (S["bpm"] * (0.94 + (seed % 5) * 0.03)); bar = beat * 4
|
|
69
|
+
nbars = int(dur / bar) + 2
|
|
70
|
+
scale = [0, 3, 5, 7, 10, 12, 15]
|
|
71
|
+
motif = [rng.choice(scale) for _ in range(8)]
|
|
72
|
+
for b in range(nbars):
|
|
73
|
+
t0 = b * bar; root, ch = S["prog"][b % 4]
|
|
74
|
+
# harmony
|
|
75
|
+
if S["lead"] == "pianoarp":
|
|
76
|
+
for s in range(8): add(m, 0.09 * epiano(nf(ch[s % len(ch)] + (12 if s >= 4 else 0)), beat * 1.2), t0 + s * beat / 2)
|
|
77
|
+
if S["lead"] == "offchord":
|
|
78
|
+
for q in range(4):
|
|
79
|
+
for nn in ch: add(m, 0.035 * pluck(nf(nn), beat * 0.35), t0 + q * beat + beat / 2)
|
|
80
|
+
if S["lead"] in ("epiano", "pluckmel"):
|
|
81
|
+
for i, nn in enumerate(ch): add(m, 0.12 * epiano(nf(nn), bar * 0.95), t0 + i * 0.012)
|
|
82
|
+
if b % 2 == 1: add(m, 0.07 * epiano(nf(ch[1]), beat * 1.5), t0 + beat * 2.5)
|
|
83
|
+
if S.get("padon") or S["lead"] in ("bellmel", "arp8"):
|
|
84
|
+
add(m, 0.10 * pad([nf(x) for x in ch], bar), t0)
|
|
85
|
+
# bass
|
|
86
|
+
if S["drum"] == "trap":
|
|
87
|
+
add(m, 0.38 * sub(nf(root - 12), beat * 1.8), t0); add(m, 0.3 * sub(nf(root - 12), beat * 1.2, glide=nf(root - 7)), t0 + beat * 2.5)
|
|
88
|
+
else:
|
|
89
|
+
add(m, 0.32 * sub(nf(root - 12), beat * 1.9), t0); add(m, 0.25 * sub(nf(root - 12), beat * 1.4), t0 + beat * 2)
|
|
90
|
+
# leads
|
|
91
|
+
if S["lead"] == "arp8":
|
|
92
|
+
for s in range(8): add(m, 0.07 * pluck(nf(ch[s % len(ch)] + 12), beat * 0.6), t0 + s * beat / 2)
|
|
93
|
+
elif S["lead"] == "arp16":
|
|
94
|
+
for s in range(16): add(m, 0.045 * pluck(nf(ch[(s * 2) % len(ch)] + 12), beat * 0.3), t0 + s * beat / 4)
|
|
95
|
+
elif S["lead"] in ("bellmel", "pluckmel") and b % 4 >= 1:
|
|
96
|
+
inst = bell if S["lead"] == "bellmel" else pluck
|
|
97
|
+
for s, iv in enumerate(motif[:4] if b % 2 else motif[4:]):
|
|
98
|
+
add(m, 0.07 * inst(nf(root + 24 + iv), beat * 0.9), t0 + s * beat)
|
|
99
|
+
# drums
|
|
100
|
+
for q in range(4):
|
|
101
|
+
tb = t0 + q * beat
|
|
102
|
+
if S["drum"] == "lofi":
|
|
103
|
+
sw = beat * 0.08
|
|
104
|
+
if q in (0,): add(m, 0.55 * kick(), tb)
|
|
105
|
+
if q == 2: add(m, 0.4 * kick(), tb + beat / 2)
|
|
106
|
+
if q in (1, 3): add(m, 0.16 * lp(snare(rng), 3000), tb)
|
|
107
|
+
add(m, 0.05 * hat(rng), tb); add(m, 0.035 * hat(rng), tb + beat / 2 + sw)
|
|
108
|
+
elif S["drum"] == "pop":
|
|
109
|
+
if q in (0, 2): add(m, 0.55 * kick(), tb)
|
|
110
|
+
if q == 2: add(m, 0.35 * kick(), tb + beat * 0.75)
|
|
111
|
+
if q in (1, 3): add(m, 0.3 * clap(rng), tb)
|
|
112
|
+
add(m, 0.04 * hat(rng), tb + beat / 2)
|
|
113
|
+
elif S["drum"] == "four":
|
|
114
|
+
add(m, 0.5 * kick(), tb); add(m, 0.05 * hat(rng, 0.08), tb + beat / 2)
|
|
115
|
+
if q in (1, 3): add(m, 0.12 * snare(rng), tb)
|
|
116
|
+
elif S["drum"] == "house":
|
|
117
|
+
add(m, 0.5 * kick(), tb); add(m, 0.07 * hat(rng, 0.09), tb + beat / 2)
|
|
118
|
+
if q in (1, 3): add(m, 0.22 * clap(rng), tb)
|
|
119
|
+
elif S["drum"] == "soft":
|
|
120
|
+
if q == 0: add(m, 0.35 * kick(), tb)
|
|
121
|
+
if q == 2: add(m, 0.08 * lp(snare(rng, 0.3), 2000), tb)
|
|
122
|
+
add(m, 0.02 * hat(rng, 0.08), tb + beat / 2)
|
|
123
|
+
elif S["drum"] == "trap":
|
|
124
|
+
if q == 0: add(m, 0.5 * kick(), tb)
|
|
125
|
+
if q == 2: add(m, 0.3 * clap(rng), tb)
|
|
126
|
+
rolls = 6 if (q == 3 and b % 2) else 2
|
|
127
|
+
for h in range(rolls): add(m, 0.04 * hat(rng, 0.03), tb + h * beat / rolls)
|
|
128
|
+
if S.get("vinyl"):
|
|
129
|
+
crack = (rng.random(N) > 0.9993) * rng.standard_normal(N) * 0.3
|
|
130
|
+
m += lp(crack + 0.004 * rng.standard_normal(N), 4000)
|
|
131
|
+
m = m[: int(dur * SR)]
|
|
132
|
+
t = np.arange(len(m)) / SR
|
|
133
|
+
m *= np.clip(t / 0.6, 0, 1) * np.clip((dur - 0.2 - t) / 1.2, 0, 1)
|
|
134
|
+
return m / (np.abs(m).max() + 1e-9)
|
|
135
|
+
|
|
136
|
+
# ---- sfx ----
|
|
137
|
+
def whoosh(rng, d=0.35):
|
|
138
|
+
n = int(d * SR); x = rng.standard_normal(n)
|
|
139
|
+
a = lp(x, 900); b = lp(x, 3500); k = np.linspace(0, 1, n)
|
|
140
|
+
return (a * (1 - k) + b * k) * np.sin(np.linspace(0, np.pi, n)) ** 2
|
|
141
|
+
def pop(f0=500, f1=1100, d=0.12):
|
|
142
|
+
n = int(d * SR); f = np.linspace(f0, f1, n)
|
|
143
|
+
return np.sin(2 * np.pi * np.cumsum(f) / SR) * dec(n, d / 3, 0.002)
|
|
144
|
+
def tick(d=0.03):
|
|
145
|
+
n = int(d * SR); t = np.arange(n) / SR; return np.sin(2 * np.pi * 2200 * t) * np.exp(-t * 120)
|
|
146
|
+
def glitch(rng):
|
|
147
|
+
x = np.zeros(int(0.25 * SR))
|
|
148
|
+
for i in range(6):
|
|
149
|
+
add(x, 0.6 * pop(rng.uniform(300, 1600), rng.uniform(200, 900), 0.03), i * 0.035)
|
|
150
|
+
return x
|
|
151
|
+
|
|
152
|
+
def musica_de(vid, m, dur, N):
|
|
153
|
+
"""m = None (sin música) | {"estilo": "lofi"} (generada) | {"archivo", "volumen", "inicio"} (la tuya)."""
|
|
154
|
+
if not m: return np.zeros(N)
|
|
155
|
+
if m.get("estilo"):
|
|
156
|
+
if m["estilo"] not in STYLES: raise SystemExit(f"Música desconocida: {m['estilo']!r}. Opciones: {', '.join(STYLES)}")
|
|
157
|
+
return make_music(m["estilo"], dur, seed=sum(map(ord, vid)))
|
|
158
|
+
from preparar import leer_audio
|
|
159
|
+
import pyloudnorm as pyln
|
|
160
|
+
x = leer_audio(m["archivo"], SR)[int(float(m.get("inicio", 0)) * SR):]
|
|
161
|
+
if not len(x): raise SystemExit(f"La música {m['archivo']} está vacía (¿inicio mayor que su duración?)")
|
|
162
|
+
try:
|
|
163
|
+
lufs = pyln.Meter(SR).integrated_loudness(x)
|
|
164
|
+
if np.isfinite(lufs): x = x * 10 ** ((-23 - lufs) / 20) # nivel de fondo parejo, se ajusta con `volumen`
|
|
165
|
+
except ValueError: pass # pista muy corta para medir: se usa tal cual
|
|
166
|
+
x = np.tile(x, int(np.ceil(N / len(x))))[:N] * float(m.get("volumen", 1))
|
|
167
|
+
fi, fo = int(0.3 * SR), int(1.2 * SR)
|
|
168
|
+
x[:fi] *= np.linspace(0, 1, fi); x[-fo:] *= np.linspace(1, 0, fo)
|
|
169
|
+
return x / 0.22 # mix() la multiplica por 0.22 como a la generada
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def efecto(nombre, rng):
|
|
173
|
+
"""Efectos para el motor de capas: pop, whoosh, tick, glitch, ding."""
|
|
174
|
+
if nombre == "whoosh": return 0.18 * whoosh(rng)
|
|
175
|
+
if nombre == "tick": return 0.4 * tick()
|
|
176
|
+
if nombre == "glitch": return 0.3 * glitch(rng)
|
|
177
|
+
if nombre == "ding": return 0.2 * bell(nf(88), 0.8)
|
|
178
|
+
return 0.14 * pop()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def capas_con_sonido(sp):
|
|
182
|
+
"""Capas de la escena (y de sus grupos) que piden un efecto de sonido."""
|
|
183
|
+
out, pila = [], list(sp.get("capas") or [])
|
|
184
|
+
while pila:
|
|
185
|
+
c = pila.pop(0)
|
|
186
|
+
if not isinstance(c, dict): continue
|
|
187
|
+
if c.get("sonido"): out.append(c)
|
|
188
|
+
pila.extend(c.get("capas") or [])
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def mix(vid, style, outdir, TIM, SPEC, durs, ev):
|
|
193
|
+
"""Mezcla voz + música (con ducking) + efectos y escribe mix.wav. style=None: sin música."""
|
|
194
|
+
dur = sum(durs); N = int(dur * SR); rng = np.random.default_rng(11)
|
|
195
|
+
music = musica_de(vid, style, dur, N)
|
|
196
|
+
sfx = np.zeros(N)
|
|
197
|
+
for si, (m, sp) in enumerate(zip(TIM, SPEC)):
|
|
198
|
+
t0 = m["t0"]; ty = sp["type"]
|
|
199
|
+
if si and sp.get("transicion", "auto") != "corte": add(sfx, 0.18 * whoosh(rng), t0 - 0.12)
|
|
200
|
+
if ty in ("title", "text", "icon", "year", "aliens", "memory"): add(sfx, 0.13 * pop(), t0 + 0.3)
|
|
201
|
+
if ty == "compare": add(sfx, 0.16 * pop(600, 1500, 0.2), t0 + ev(si, sp["at"]) - 0.2)
|
|
202
|
+
if ty == "card": add(sfx, 0.35 * glitch(rng), t0 + ev(si, sp["at"]))
|
|
203
|
+
if ty == "dialog":
|
|
204
|
+
tc = t0 + ev(si, sp["click"]) - 0.15; add(sfx, 0.4 * tick(), tc); add(sfx, 0.13 * pop(700, 1400), tc + 0.1)
|
|
205
|
+
if ty == "morph": add(sfx, 0.12 * pop(300, 900, 0.8), t0 + ev(si, sp["at"]) - 0.1)
|
|
206
|
+
if ty == "text" and sp.get("strike"):
|
|
207
|
+
for i in range(len(sp["rows"])): add(sfx, 0.2 * whoosh(rng, 0.15), t0 + m["speech"] * 0.55 + i * 0.12)
|
|
208
|
+
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)
|
|
214
|
+
voice, _ = sf.read(os.path.join(outdir, "voz.wav"))
|
|
215
|
+
voice = resample_poly(voice, 147, 80)
|
|
216
|
+
v = np.zeros(N); v[: min(N, len(voice))] = voice[:N]
|
|
217
|
+
w = int(0.15 * SR); envv = np.convolve(np.abs(v), np.ones(w) / w, mode="same")
|
|
218
|
+
music *= 1 - 0.6 * np.clip(envv / 0.05, 0, 1)
|
|
219
|
+
out = 1.3 * v + 0.22 * music + 0.6 * sfx
|
|
220
|
+
out = np.tanh(out * 1.1) * 0.89
|
|
221
|
+
sf.write(os.path.join(outdir, "mix.wav"), np.stack([out, out], 1).astype(np.float32), SR)
|