boosterarena-mcp 0.3.1 → 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/LEEME.md +4 -0
- package/dist/nucleo.mjs +8 -4
- 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
|
@@ -1712,6 +1712,8 @@ function conLoDelMcp(lista2, fn) {
|
|
|
1712
1712
|
const recolocar = props("recolocar");
|
|
1713
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).");
|
|
1714
1714
|
recolocar.y = num("Nuevo borde superior en %. Tambi\xE9n puede quedar fuera del slide, como `x`.");
|
|
1715
|
+
props("poner_figura").alto = num("Alto en % del alto del slide, independiente del ancho. Para los paneles de capa: una elipse blanca de w 82 \xD7 alto 77, o un rect de 83 \xD7 71. Sin esto, la figura guarda su proporci\xF3n de siempre.");
|
|
1716
|
+
recolocar.alto = num("Nuevo alto en %: para cajas (Revelador, Ciclo, QR\u2026), zonas calientes y FIGURAS, independiente del ancho.");
|
|
1715
1717
|
props("cambiar_estilo").gris_al_tocar = bool("true = en el \u25B6 Player, al tocar el objeto se vuelve gris (y otro toque le devuelve el color). Le dice al coach qu\xE9 botones ya us\xF3. false lo quita.");
|
|
1716
1718
|
const i = lista2.findIndex((t) => t.function.name === "poner_emoji");
|
|
1717
1719
|
lista2.splice(i + 1, 0, fn(
|
|
@@ -3339,8 +3341,8 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
|
|
|
3339
3341
|
name: txt(args.nombre) || "Audio",
|
|
3340
3342
|
vol: AUDIO_VOL_INICIAL
|
|
3341
3343
|
};
|
|
3342
|
-
plantar(b, el, args.ref,
|
|
3343
|
-
b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB`);
|
|
3344
|
+
const { dentroDe } = plantar(b, el, args.ref, args.capa);
|
|
3345
|
+
b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB${dentroDe ? ` en la capa \xAB${dentroDe}\xBB` : ""}`);
|
|
3344
3346
|
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.`;
|
|
3345
3347
|
}
|
|
3346
3348
|
case "poner_figura": {
|
|
@@ -3354,7 +3356,9 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
|
|
|
3354
3356
|
x: pct(args.x, 40),
|
|
3355
3357
|
y: pct(args.y, 35),
|
|
3356
3358
|
w,
|
|
3357
|
-
|
|
3359
|
+
// `alto` libre solo desde el MCP: los paneles de capa de los maestros son elipses de 82 × 77
|
|
3360
|
+
// y rectángulos de 83 × 71, estirados a mano en el editor (Planificatron, corpus 2026-09-30).
|
|
3361
|
+
h: b.estricto && num3(args.alto, 0) > 0 ? pct(args.alto, 20) : def.alto === "cuadrado" ? squareH(w) : squareH(w) * def.alto,
|
|
3358
3362
|
fill: txt(args.color) || def.color,
|
|
3359
3363
|
strokeW: 0,
|
|
3360
3364
|
strokeStyle: "solid",
|
|
@@ -3655,7 +3659,7 @@ Pide \`describir("${el.widget}")\` para ver la forma exacta y vuelve a intentarl
|
|
|
3655
3659
|
case "recolocar": {
|
|
3656
3660
|
const el = buscarEl(b, txt(args.objetivo));
|
|
3657
3661
|
if (!el) return no(b, `No encuentro ning\xFAn objeto "${txt(args.objetivo)}".`);
|
|
3658
|
-
const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot;
|
|
3662
|
+
const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot || !!b.estricto && el.kind === "shape";
|
|
3659
3663
|
if (num3(args.alto, 0) > 0 && !altoLibre) {
|
|
3660
3664
|
return no(b, `${describirEl(el)} no acepta \`alto\`: se deformar\xEDa. Solo las cajas (Revelador, Ciclo, Label It, QR\u2026) y las zonas calientes. Cambia su tama\xF1o con \`w\` y el alto le sigue.`);
|
|
3661
3665
|
}
|
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
|
+
}
|