boosterarena-mcp 0.3.0 → 0.3.2
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/LEEME.md +4 -0
- package/dist/nucleo.mjs +29 -5
- package/package.json +1 -1
- package/servidor.mjs +56 -1
- package/subidas.mjs +20 -14
package/LEEME.md
CHANGED
|
@@ -77,6 +77,10 @@ ninguna de las dos cosas, pero se las traga sin distinguirlas de las que subiste
|
|
|
77
77
|
> devuelve la misma URL y ocupa una sola vez. Para quien repite la misma ilustración en cientos
|
|
78
78
|
> de clases, eso no es un detalle.
|
|
79
79
|
|
|
80
|
+
**Voz de Booster** — `generar_voz` crea el audio de un texto con una de las 10 voces de Booster
|
|
81
|
+
(las mismas del editor, iguales en cualquier máquina) y lo pone en el slide o en una capa, listo
|
|
82
|
+
para `sonar`. Es del plan Pro y gasta letras de la cuota mensual de la cuenta.
|
|
83
|
+
|
|
80
84
|
**Encargos** — quitar el fondo de una imagen, el Sombrero Mágico y el Transformador se resuelven
|
|
81
85
|
sin abrir el navegador.
|
|
82
86
|
|
package/dist/nucleo.mjs
CHANGED
|
@@ -1703,6 +1703,12 @@ function conLoDelMcp(lista2, fn) {
|
|
|
1703
1703
|
fondo.url = str("Una imagen ya SUBIDA (con `subir_imagen`), https://. Usa esto, `busca` o `color`: uno solo.");
|
|
1704
1704
|
fondo.capa = str("El `ref` o el nombre de una CAPA para ponerle el fondo a ella en vez de al slide. Cada capa puede llevar el suyo.");
|
|
1705
1705
|
props("nueva_capa").fondo = str("Fondo propio de la capa: la URL https:// de una imagen ya subida o un color en hex. Sin esto, la capa deja ver el slide de debajo.");
|
|
1706
|
+
for (const t of lista2) {
|
|
1707
|
+
const p = t.function.parameters.properties;
|
|
1708
|
+
if (!t.function.name.startsWith("poner_") || !p.x || t.function.name === "poner_texto_plantilla") continue;
|
|
1709
|
+
p.x = num("Borde IZQUIERDO, en % del ancho del slide. Puede ser NEGATIVO o pasar de 100 para que nazca FUERA del slide y entre con una trayectoria.");
|
|
1710
|
+
p.y = num("Borde SUPERIOR, en % del alto del slide. Tambi\xE9n puede quedar fuera, como `x`.");
|
|
1711
|
+
}
|
|
1706
1712
|
const recolocar = props("recolocar");
|
|
1707
1713
|
recolocar.x = num("Nuevo borde izquierdo en %. Puede ser NEGATIVO o pasar de 100 para dejar el objeto FUERA del slide y que entre con una trayectoria (el monstruo que entra solo: x = -50).");
|
|
1708
1714
|
recolocar.y = num("Nuevo borde superior en %. Tambi\xE9n puede quedar fuera del slide, como `x`.");
|
|
@@ -3111,6 +3117,25 @@ function armarAccion(b, a, el) {
|
|
|
3111
3117
|
}
|
|
3112
3118
|
}
|
|
3113
3119
|
function ejecutar(b, nombre, args, fab) {
|
|
3120
|
+
const antes = b.creados.length;
|
|
3121
|
+
const dijo = ejecutarUna(b, nombre, args, fab);
|
|
3122
|
+
return b.estricto && !b.fallo ? dijo + dejarFuera(b, nombre, args, antes) : dijo;
|
|
3123
|
+
}
|
|
3124
|
+
var enElAreaDeTrabajo = (v, lado) => Math.max(-lado - 30, Math.min(130, v));
|
|
3125
|
+
function dejarFuera(b, nombre, args, antes) {
|
|
3126
|
+
if (!nombre.startsWith("poner_") || ["poner_fondo", "poner_arena", "poner_audio", "poner_texto_plantilla"].includes(nombre)) return "";
|
|
3127
|
+
if (b.creados.length !== antes + 1) return "";
|
|
3128
|
+
const el = buscarEl(b, b.creados[antes]);
|
|
3129
|
+
if (!el) return "";
|
|
3130
|
+
const x = typeof args.x === "number" && Number.isFinite(args.x) ? args.x : null;
|
|
3131
|
+
const y = typeof args.y === "number" && Number.isFinite(args.y) ? args.y : null;
|
|
3132
|
+
const sale = (v, lado) => v !== null && (v < 0 || v + lado > 100);
|
|
3133
|
+
if (!sale(x, el.w) && !sale(y, el.h)) return "";
|
|
3134
|
+
if (sale(x, el.w)) el.x = +enElAreaDeTrabajo(x, el.w).toFixed(2);
|
|
3135
|
+
if (sale(y, el.h)) el.y = +enElAreaDeTrabajo(y, el.h).toFixed(2);
|
|
3136
|
+
return " Qued\xF3 FUERA del slide (o asomando): no se ve entero hasta que una trayectoria lo meta.";
|
|
3137
|
+
}
|
|
3138
|
+
function ejecutarUna(b, nombre, args, fab) {
|
|
3114
3139
|
switch (nombre) {
|
|
3115
3140
|
// ── Poner cosas ──────────────────────────────────────────────────────────
|
|
3116
3141
|
case "poner_texto": {
|
|
@@ -3314,8 +3339,8 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
|
|
|
3314
3339
|
name: txt(args.nombre) || "Audio",
|
|
3315
3340
|
vol: AUDIO_VOL_INICIAL
|
|
3316
3341
|
};
|
|
3317
|
-
plantar(b, el, args.ref,
|
|
3318
|
-
b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB`);
|
|
3342
|
+
const { dentroDe } = plantar(b, el, args.ref, args.capa);
|
|
3343
|
+
b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB${dentroDe ? ` en la capa \xAB${dentroDe}\xBB` : ""}`);
|
|
3319
3344
|
return `Puesto el audio "${el.name}" (id ${el.id}). Est\xE1 FUERA del slide, como un mando: para que suene, progr\xE1malo con \`programar\` (al hacer clic \u2192 reproducir audio) o ponlo al iniciar el slide.`;
|
|
3320
3345
|
}
|
|
3321
3346
|
case "poner_figura": {
|
|
@@ -3640,9 +3665,8 @@ Pide \`describir("${el.widget}")\` para ver la forma exacta y vuelve a intentarl
|
|
|
3640
3665
|
el.w = w;
|
|
3641
3666
|
}
|
|
3642
3667
|
if (num3(args.alto, 0) > 0) el.h = pct(args.alto, el.h);
|
|
3643
|
-
|
|
3644
|
-
if (typeof args.
|
|
3645
|
-
if (typeof args.y === "number") el.y = b.estricto ? +fuera(args.y, el.h).toFixed(2) : pct(args.y, el.y);
|
|
3668
|
+
if (typeof args.x === "number") el.x = b.estricto ? +enElAreaDeTrabajo(args.x, el.w).toFixed(2) : pct(args.x, el.x);
|
|
3669
|
+
if (typeof args.y === "number") el.y = b.estricto ? +enElAreaDeTrabajo(args.y, el.h).toFixed(2) : pct(args.y, el.y);
|
|
3646
3670
|
const afuera = b.estricto && (el.x < 0 || el.y < 0 || el.x + el.w > 100 || el.y + el.h > 100);
|
|
3647
3671
|
if (!afuera) encajar(el);
|
|
3648
3672
|
b.tocados.push(el.id);
|
package/package.json
CHANGED
package/servidor.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
32
32
|
import { createRequire } from 'node:module';
|
|
33
33
|
import { tokenDeAcceso } from './sesion.mjs';
|
|
34
34
|
import { abrir, carpetas, crear, equipos, guardar, listar, resolverDestino } from './proyectos.mjs';
|
|
35
|
-
import { subirArchivo } from './subidas.mjs';
|
|
35
|
+
import { subirArchivo, subirBytes } from './subidas.mjs';
|
|
36
36
|
import { rematarEncargo } from './encargos.mjs';
|
|
37
37
|
|
|
38
38
|
// La versión se lee del package.json: escrita a mano se queda vieja en silencio, y es lo que
|
|
@@ -228,6 +228,31 @@ const HERRAMIENTAS_PROYECTO = [
|
|
|
228
228
|
required: ['ruta'],
|
|
229
229
|
},
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: 'generar_voz',
|
|
233
|
+
description: 'Genera un audio con la VOZ DE BOOSTER (siempre la misma en cualquier máquina, no la del '
|
|
234
|
+
+ 'navegador) y lo PONE en el slide o en una capa, listo para sonar. Es lo que hace el editor con '
|
|
235
|
+
+ '«Leer en voz alta 🔊». Después prográmalo con `programar` y accion `sonar` (destino = su ref): al '
|
|
236
|
+
+ 'tocar algo, en un clickStep, o con evento layerShown para que suene al abrirse la capa. Prefiérelo '
|
|
237
|
+
+ 'a `leerEnVoz`, que depende de las voces del equipo donde se proyecte. ⚠️ Es del plan Pro y gasta '
|
|
238
|
+
+ 'letras de la cuota mensual de la cuenta (2.000 por audio, 50.000 al mes).',
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
texto: { type: 'string', description: 'Lo que dice. En inglés. Hasta 2.000 letras.' },
|
|
243
|
+
voz: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
description: 'La voz. EE. UU.: af_heart (mujer, la mejor), af_bella, af_nicole, am_michael (hombre), '
|
|
246
|
+
+ 'am_fenrir, am_puck. Reino Unido: bf_emma (mujer), bf_isabella, bm_george (hombre), bm_lewis. '
|
|
247
|
+
+ 'Sin esto, af_heart.',
|
|
248
|
+
},
|
|
249
|
+
ref: { type: 'string', description: 'Etiqueta corta para programarlo después en esta tanda. Ej: "voz_cat".' },
|
|
250
|
+
nombre: { type: 'string', description: 'Cómo se llama el chip del audio en el editor. Sin esto, el principio del texto.' },
|
|
251
|
+
capa: { type: 'string', description: 'El `ref` o el nombre de una capa para ponerlo DENTRO de ella. Vacío = en el slide.' },
|
|
252
|
+
},
|
|
253
|
+
required: ['texto'],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
231
256
|
{
|
|
232
257
|
name: 'transformar_en',
|
|
233
258
|
description: 'Hace que un objeto se CONVIERTA en otras imágenes al hacerle clic en el ▶ Player '
|
|
@@ -399,6 +424,36 @@ async function llamar(nombre, args = {}) {
|
|
|
399
424
|
+ `Dónde: ${nombre === 'crear_proyecto' ? donde : (p.team_id ? `equipo ${p.team_id}` : 'tu espacio Personal')} — se abre desde la galería de https://boosterarena.com\n`
|
|
400
425
|
+ `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n\n${resumenDelDoc(yo.borrador.doc)}`;
|
|
401
426
|
}
|
|
427
|
+
// La voz de Booster: la MISMA función que usa el editor (`/api/voz`, que pone el candado Pro y
|
|
428
|
+
// descuenta las letras en el servidor), luego se sube el MP3 y se planta como un audio normal.
|
|
429
|
+
if (nombre === 'generar_voz') {
|
|
430
|
+
exigeProyecto();
|
|
431
|
+
const texto = String(args.texto ?? '').trim();
|
|
432
|
+
if (!texto) throw new Error('Falta el `texto`.');
|
|
433
|
+
if (texto.length > 2000) throw new Error(`El texto tiene ${texto.length} letras y el máximo son 2.000. Pártelo en dos audios.`);
|
|
434
|
+
const t = await token();
|
|
435
|
+
const r = await fetch('https://boosterarena.com/api/voz', {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({ texto, voz: String(args.voz ?? '').trim() || 'af_heart' }),
|
|
439
|
+
});
|
|
440
|
+
if (!r.ok) {
|
|
441
|
+
const info = await r.json().catch(() => ({}));
|
|
442
|
+
const m = info.error;
|
|
443
|
+
throw new Error(m === 'PRO_REQUIRED' ? 'La voz de Booster es del plan Pro y esta cuenta no lo tiene. Díselo a tu usuario: se activa desde boosterarena.com. Mientras tanto, `subir_audio` con un audio propio.'
|
|
444
|
+
: m === 'MONTHLY_LIMIT' ? 'Se acabaron las letras de voz de este mes en esta cuenta. Mientras tanto, `subir_audio` con un audio propio.'
|
|
445
|
+
: m === 'VOZ_DESCONOCIDA' ? `No existe la voz "${info.voz}". Usa una de la lista de \`voz\`.`
|
|
446
|
+
: m === 'NO_AUTH' ? 'La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.'
|
|
447
|
+
: `El servidor de voz contestó ${r.status}: ${info.detail ?? m ?? 'sin detalle'}.`);
|
|
448
|
+
}
|
|
449
|
+
const restantes = Number(r.headers.get('X-Voz-Restantes') ?? -1);
|
|
450
|
+
const url = await subirBytes(t, Buffer.from(await r.arrayBuffer()), 'mp3');
|
|
451
|
+
yo.borrador.fallo = false;
|
|
452
|
+
const nombreChip = String(args.nombre ?? '').trim() || texto.slice(0, 28).trim();
|
|
453
|
+
const dijo = nucleo.ejecutar(yo.borrador, 'poner_audio', { url, ref: args.ref, nombre: nombreChip, capa: args.capa }, fabricas());
|
|
454
|
+
if (yo.borrador.fallo) throw new Error(dijo);
|
|
455
|
+
return `Voz generada y puesta. ${dijo}\nURL: ${url}${restantes >= 0 ? `\nLetras de voz que le quedan este mes a la cuenta: ${restantes}.` : ''}`;
|
|
456
|
+
}
|
|
402
457
|
if (nombre === 'subir_imagen' || nombre === 'subir_audio') {
|
|
403
458
|
const t = await token();
|
|
404
459
|
const r = await subirArchivo(t, args.ruta, nombre === 'subir_imagen' ? 'imagen' : 'audio');
|
package/subidas.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Es la pieza que le faltaba a la fábrica de Planificatron. Ellos generan FUERA de Booster
|
|
5
5
|
// —las imágenes con Flux en su propia gráfica, la voz con edge-tts— y el resultado entra por
|
|
6
|
-
// aquí. Booster no genera
|
|
7
|
-
//
|
|
6
|
+
// aquí. Booster no genera imágenes (se retiró por costo); la voz sí, desde el 2026-09-21, con
|
|
7
|
+
// `generar_voz`, que también sube por aquí (`subirBytes`). La fábrica no le añade capacidades a Booster, le añade un
|
|
8
8
|
// PROVEEDOR DE ARCHIVOS. Un MP3 de un TTS y un MP3 que subió un maestro son el mismo elemento
|
|
9
9
|
// con una URL de R2, y el documento no puede notar la diferencia.
|
|
10
10
|
//
|
|
@@ -56,18 +56,7 @@ export async function subirArchivo(token, ruta, familia) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const cuerpo = await readFile(ruta);
|
|
59
|
-
const
|
|
60
|
-
method: 'POST',
|
|
61
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': tipo, 'X-Ext': ext },
|
|
62
|
-
body: cuerpo,
|
|
63
|
-
});
|
|
64
|
-
if (!r.ok) {
|
|
65
|
-
let detalle = String(r.status);
|
|
66
|
-
try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
|
|
67
|
-
if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
|
|
68
|
-
throw new Error(`No se pudo subir: ${detalle}`);
|
|
69
|
-
}
|
|
70
|
-
const { url } = await r.json();
|
|
59
|
+
const url = await subirBytes(token, cuerpo, ext);
|
|
71
60
|
|
|
72
61
|
const kb = Math.round(info.size / 1024);
|
|
73
62
|
const pesado = familia === 'imagen' && ['png', 'jpg', 'jpeg'].includes(ext) && info.size > AVISO_PESO;
|
|
@@ -82,3 +71,20 @@ export async function subirArchivo(token, ruta, familia) {
|
|
|
82
71
|
: '',
|
|
83
72
|
};
|
|
84
73
|
}
|
|
74
|
+
|
|
75
|
+
/** Sube unos bytes ya en memoria (la voz recién generada) y devuelve su URL. Mismo camino que
|
|
76
|
+
* `subirArchivo`: `functions/api/upload.ts`, con el token del maestro. */
|
|
77
|
+
export async function subirBytes(token, cuerpo, ext) {
|
|
78
|
+
const r = await fetch(SUBIDA, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': TIPOS[ext] ?? 'application/octet-stream', 'X-Ext': ext },
|
|
81
|
+
body: cuerpo,
|
|
82
|
+
});
|
|
83
|
+
if (!r.ok) {
|
|
84
|
+
let detalle = String(r.status);
|
|
85
|
+
try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
|
|
86
|
+
if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
|
|
87
|
+
throw new Error(`No se pudo subir: ${detalle}`);
|
|
88
|
+
}
|
|
89
|
+
return (await r.json()).url;
|
|
90
|
+
}
|