Medios, canvases y huddles
Medios, canvases y huddles
Los capítulos anteriores cubrieron texto: mensajes, hilos, reacciones, DMs. Este cubre lo que no es texto. Tres superficies que la gente suele meter en la misma bolsa y que en Buzz funcionan de maneras muy diferentes: medios (archivos que subes al almacén del relay y adjuntas a un mensaje), canvases (un documento Markdown compartido por canal) y huddles (voz en vivo dentro del propio relay).
Empiezo por el estado de cada una, porque es lo primero que hay que saber antes de apoyar un flujo de trabajo encima. El README de Buzz trae una tabla de tres columnas —Works today, Being wired up y Strong opinions, pending code— y esto es lo que dice de este capítulo:
| Pieza | Columna del README | Qué significa en la práctica |
|---|---|---|
| Medios y canvases | ✅ Works today | Producción. Los límites y validaciones están fijados en código. |
| Huddles de voz | No figura en ninguna columna | El README no los nombra ni como listos ni como pendientes; lo que sí está en el código es el transporte: relay Opus por WebSocket, sin SFU externo. |
| Eventos de ciclo de vida de huddle | 🚧 Being wired up | Los kinds 48100-48103 se emiten, pero la superficie está asentándose. |
| Grabación de huddles | No existe | No hay kinds reservados ni productor. El registro de lo hablado solo llega por la transcripción local, y hay que activarla. |
| Clientes móviles | 🚧 Being wired up | iOS y Android en Flutter. Ver el capítulo 13. |
Nada de lo que sigue es aspiracional salvo donde lo diga explícitamente.
Medios: el almacén Blossom
Qué es Blossom y por qué importa
Buzz no guarda tus archivos dentro de los eventos Nostr. Los guarda en un almacén aparte que habla Blossom (las especificaciones BUD-01 y BUD-02) sobre almacenamiento S3 o MinIO. El evento solo lleva la referencia.
Lo importante del modelo: el almacén está direccionado por contenido. La clave
de un blob es el sha256 de sus bytes. De ahí salen tres consecuencias que vas a
notar: subir dos veces el mismo archivo no ocupa el doble —la segunda subida hace
cortocircuito y devuelve el mismo descriptor—; la URL no contiene el nombre del
fichero, porque el relay nunca aprende cómo se llamaba en tu disco; y cambiar
un byte produce otra URL, así que no existe “actualizar la imagen manteniendo el
enlace”.
El relay expone tres endpoints: PUT /upload para subir un blob (el endpoint BUD-02
actual), PUT /media/upload como alias heredado, y GET/HEAD sobre
/media/{sha256_ext} para descargar o sondear.
El camino que recorre un archivo
flowchart TD
A["Eliges o pegas un archivo"] --> B["El cliente firma un evento Blossom kind 24242"]
B --> C["PUT al endpoint upload con la cabecera Authorization"]
C --> D{"Magic bytes"}
D -- "tipo no permitido" --> X["Rechazo"]
D -- "ok" --> E{"Tamaño bajo el límite"}
E -- "no" --> X
E -- "sí" --> F{"Imagen sin metadatos incrustados"}
F -- "lleva EXIF, XMP, ICC o texto PNG" --> X
F -- "limpia" --> G{"Menos de 25 megapíxeles"}
G -- "no" --> X
G -- "sí" --> H["Se calcula el sha256 y se guarda el blob"]
H --> I["Miniatura de 320 px y blurhash del lado del servidor"]
I --> J["Respuesta: BlobDescriptor en JSON"]
Dos detalles del camino: el Content-Type que envía el cliente no se cree
nunca —el tipo se deduce de los magic bytes, así que un MP4 disfrazado de PNG se
rechaza— y la autorización Blossom se firma por intento, con ventana de 10
minutos, validando el tag server contra el host al que llegó la petición y no
contra un dominio global.
Límites duros
Estos son los valores por defecto. Un operador puede cambiarlos por variables de entorno; si necesitas otros, esa conversación va al curso de administrador, no aquí.
| Tipo | Límite por defecto |
|---|---|
| Imagen (JPEG, PNG, WebP) | 50 MB |
| GIF | 10 MB |
| Video MP4 | 500 MB |
| Archivo genérico (documentos, archivos comprimidos, datos) | 100 MB |
| Píxeles de una imagen | 25 megapíxeles |
El tope de 25 megapíxeles es protección contra image bombs: si las dimensiones no se pueden leer, el archivo se rechaza en vez de dejarlo llegar al decodificador completo. Falla cerrado a propósito.
Qué tipos se aceptan y cuáles no
Hay tres caminos de subida distintos con reglas distintas.
Camino de imagen — lista de permitidos y nada más: image/jpeg, image/png,
image/gif, image/webp.
Camino de video — MP4, con su propia validación de estructura ISO-BMFF.
Camino de archivo genérico — todo lo demás, con una lista de denegados
explícita: text/html, application/xhtml+xml, image/svg+xml,
application/javascript, text/javascript, y los ejecutables nativos
(application/x-msdownload, application/x-executable, application/x-mach-binary,
application/x-elf, application/x-msi, .apk, .dmg).
Es decir: HTML, JavaScript y SVG están prohibidos porque son los vectores
clásicos de XSS almacenado, y los ejecutables nativos porque no hay razón legítima
para alojarlos en un chat. Un archivo sin firma reconocible (texto plano, CSV,
código fuente, JSON) se acepta como application/octet-stream. Los genéricos
siempre se sirven como descarga, con Content-Disposition: attachment,
X-Content-Type-Options: nosniff y una CSP de default-src 'none'; solo las
imágenes y los videos se sirven en línea.
Las imágenes se suben sin metadatos
Este es el punto que más sorprende. El relay rechaza estructuralmente cualquier imagen que traiga metadatos incrustados: EXIF, XMP, perfiles ICC, chunks de texto en PNG o cualquier chunk privado. No es una lista negra de etiquetas EXIF, es una lista blanca de estructura de contenedor.
La razón está escrita en el código: la ubicación geográfica no solo vive en EXIF, también puede esconderse en XMP, en comentarios o en descripciones ICC. En vez de perseguir campos uno a uno, el relay exige contenedores limpios.
Quien limpia es el cliente, antes de subir. Si intentas subir con curl una foto
recién sacada del teléfono, va a ser rechazada. Sube desde la aplicación o desde
buzz upload file, que hacen el trabajo previo.
Subir desde el escritorio
En Buzz Desktop el compositor acepta las tres formas habituales: pegar, arrastrar y soltar, o adjuntar desde el selector de archivos. Detalles reales de esa ruta:
- Las subidas de video pasan por ffmpeg, que el escritorio busca en el PATH del shell de login y en las rutas típicas de Homebrew. El video se normaliza a H.264/AAC/MP4 con fast start para que pase la validación del relay, y se extrae un fotograma de póster en JPEG. Sin ffmpeg instalado no puedes subir video.
- Los HEIC de iPhone se transcodifican a JPEG antes de subir.
- Hay barra de progreso por adjunto, y las subidas siguen en segundo plano aunque cambies de canal.
Subir desde el CLI
Dos comandos, tratados en detalle en el capítulo 10:
# Sube un archivo y devuelve el descriptor Blossom en JSON
buzz upload file --file ./captura.png
# Descarga por URL de media o por sha256 con extensión
buzz media get 3f9a...c1.png -o ./captura.png
buzz media get https://relay.example.com/media/3f9a...c1.png > captura.png
Ojo con una diferencia importante: el CLI acepta menos tipos que el relay. Su
lista de permitidos es image/jpeg, image/png, image/gif, image/webp y
video/mp4. No sube documentos ni archivos comprimidos, aunque el relay sí los
acepte por el camino genérico.
Para adjuntar en el mismo paso que envías el mensaje:
buzz messages send \
--channel 6f1f7d2e-... \
--content "Así se ve el bug en la vista de canal" \
--file ./bug.png \
--file ./consola.png
Cada --file se sube primero, y luego el mensaje sale con dos cosas a la vez: un
tag imeta por archivo y una línea Markdown añadida al final del contenido.
Cómo queda referenciado el archivo
La referencia vive en un tag imeta de NIP-92, con esta forma exacta:
["imeta",
"url https://relay.example.com/media/3f9a...c1.png",
"m image/png",
"x 3f9a...c1",
"size 184320",
"dim 1920x1080",
"blurhash L6PZfSi_.AyE_3t7t7R**0o#DgR4",
"thumb https://relay.example.com/media/3f9a...c1.thumb.jpg"
]
Y el cuerpo del mensaje lleva, en una línea nueva,  o
 según el tipo. Los clientes de Buzz renderizan el medio a partir de
esa combinación.
El descriptor que devuelve la subida trae los mismos campos —url, sha256,
size, type, uploaded, dim, blurhash, thumb— más duration en segundos
cuando es video. La miniatura y el blurhash los genera el servidor: la miniatura
tiene 320 px de lado mayor conservando la proporción, y el blurhash se codifica con
4×3 componentes desde esa miniatura, no desde la imagen completa. Eso es lo que
hace que veas un borrón con los colores correctos mientras carga la imagen de
verdad.
Sobre privacidad: el DM que usas en la aplicación de escritorio no es un
gift wrap NIP-17 — es un canal de tipo dm, con el contenido en claro en la base
de datos del relay. Y sus adjuntos son todavía menos privados que el texto: la
lectura de un blob se autoriza contra la membresía de la comunidad, no contra
la del canal donde se compartió, así que quien conozca el sha256 y tenga cuenta
en el workspace puede descargarlo. Repasa el
capítulo 6 antes de
poner algo sensible en un adjunto.
Comentarios anclados a frames de un video
El README lo anuncia con una captura: “Media you can talk about. Leave comments pinned to specific frames.” El mecanismo es más simple, y más útil, de lo que parece.
El mecanismo real
Un comentario anclado a un frame es una respuesta normal del hilo cuyo cuerpo empieza con un timecode entre corchetes. Eso es todo. No hay un kind nuevo, ni una tabla aparte, ni un servicio de anotaciones.
El reproductor busca este patrón al principio del cuerpo:
[SS] → [42]
[M:SS] → [01:23]
[H:MM:SS] → [1:04:07]
[M:SS.d] → [01:23.5]
Si lo encuentra, extrae los segundos, pinta un marcador en la línea de tiempo y ordena el comentario por posición en el video en lugar de por hora de publicación. Si no lo encuentra, el comentario sigue existiendo como comentario normal, ordenado al final por fecha.
sequenceDiagram
participant U as Tú
participant P as Reproductor de Buzz
participant R as Relay
U->>P: Pausas en el segundo 83 y escribes el comentario
P->>P: Antepone el timecode del playhead
P->>R: Respuesta al mensaje del video con cuerpo "[01:23] el logo salta un frame"
R-->>P: El evento vuelve por la suscripción del canal
P->>P: Marcador en la línea de tiempo en 01:23
Lo que ves en la interfaz
El botón de abrir la revisión está arriba a la derecha del reproductor, etiquetado “Open video review”. Abre un diálogo con el video a la izquierda y el panel de comentarios a la derecha. Dentro:
- Una casilla marcada por defecto: “Comment at current frame”. Si la desmarcas, el comentario sale sin timecode.
- Reacciones rápidas que siempre estampan el momento actual: 😂 😍 😮 🙌 👍 👎.
- Velocidades de reproducción: 2, 1.75, 1.5, 1.25, 1, 0.75, 0.5 y 0.25.
- Marcadores en la barra de progreso, uno por comentario con timecode dentro de la duración del video.
- Hilos de un nivel: las respuestas se anidan bajo el comentario de nivel superior y las cadenas profundas se aplanan hacia arriba.
Regla importante: las respuestas no llevan timecode propio, heredan el momento del comentario al que responden. Una conversación sobre el segundo 83 sigue siendo sobre el segundo 83. Y si no hay comentarios ni puedes escribir, la columna desaparece y el video se queda con todo el diálogo.
Y por lo tanto, desde el CLI también
Como los comentarios son respuestas normales, puedes anclar uno desde la terminal sin ninguna herramienta especial:
buzz messages send \
--channel 6f1f7d2e-... \
--reply-to <event-id-del-mensaje-con-el-video> \
--content "[01:23] el logo salta un frame aquí"
Al abrir la revisión en el escritorio, ese comentario aparece con su marcador. Por eso un agente puede revisar una grabación y dejar observaciones ancladas sin ninguna integración nueva: solo tiene que respetar el prefijo. Sobre agentes que hacen exactamente eso, el capítulo 9.
Canvases: el documento compartido del canal
Qué es
Un canvas es un único documento Markdown por canal. Se guarda como evento kind 40100, y el canal lo lleva como una columna más de su registro, junto al tipo, la visibilidad y el topic.
El propósito es el que sugiere su nombre en el código: la narrativa del canal. El brief que no cabe en el topic, la lista de acuerdos, el plan de la release. Lo que en otras herramientas sería un mensaje fijado gigante que nadie encuentra.
Cómo se usa en el escritorio
El canvas vive en el panel de gestión del canal, en la vista Canvas (la otra vista de ese panel es Channel). Cuando no hay nada escrito, muestra literalmente “No canvas set for this channel.”
Los controles son cuatro: Create canvas o Edit canvas según haya contenido o no; un área de texto en fuente monoespaciada con el marcador “Write your canvas content in Markdown…”; Save canvas, que publica el documento completo; y Cancel. En modo lectura el contenido se renderiza como Markdown, con los mismos enlaces a canales y menciones que un mensaje.
Quién puede editarlo
Tres condiciones a la vez:
- Tienes capacidad de gestión sobre el canal (rol de owner o admin).
- Eres miembro del canal.
- El canal no es un DM. Los DMs no tienen canvas.
Si el canal está archivado, el botón de edición desaparece. Cualquier miembro que pueda ver el canal puede leer el canvas.
Desde el CLI
# Leer el canvas de un canal
buzz canvas get --channel 6f1f7d2e-...
# Escribirlo entero
buzz canvas set --channel 6f1f7d2e-... --content "# Plan de release 2.4"
# O desde un archivo, usando el convenio de stdin del CLI
cat plan.md | buzz canvas set --channel 6f1f7d2e-... --content -
Como los agentes usan el mismo CLI, un agente con membresía y rol suficiente puede mantener el canvas al día: resumir la semana, actualizar el estado de un incidente, dejar la lista de PRs pendientes.
Qué es y qué no es un canvas hoy
Conviene decirlo sin rodeos, porque la palabra “canvas” evoca otra cosa. Es un documento Markdown, uno por canal, que se reemplaza entero en cada guardado. No es una pizarra de dibujo, ni tiene bloques, ni cursores múltiples, ni edición colaborativa en tiempo real: si dos personas guardan a la vez, gana la última.
El evento es channel-scoped, así que exige el tag h con el UUID del canal
igual que un mensaje, y escribirlo requiere permiso de escritura de canales sobre el
relay, la misma categoría que crear un canal. La coordinación fina se hace en el
canal; el canvas es el sedimento.
Huddles de voz
Dónde vive el audio, de verdad
Primero una aclaración que evita mucha confusión al leer el repositorio:
- El transporte de audio en vivo está dentro de
buzz-relay, en su módulo de audio. No hay SFU externo, ni servicio aparte, ni dependencia de un proveedor de voz. Un endpoint WebSocket,wss://.../huddle/{channel_id}/audio, autentica a cada participante con un reto NIP-42, comprueba su membresía del canal, lo admite en una sala en memoria y reenvía tramas Opus opacas entre pares. - El crate
buzz-voicees otra cosa: “primitivas de voz locales reutilizables”. Contiene el motor de texto a voz local que usa el escritorio (Pocket TTS, con el bundle April) y la gestión de voces de referencia importadas. Lo consume el backend Tauri de Buzz Desktop, no el relay.
Dicho de otro modo: el relay mueve bits de audio; buzz-voice es lo que hace que un
agente suene a algo en tus altavoces, y corre en tu máquina.
El protocolo de trama v2 es una cabecera de 8 bytes big-endian —secuencia u16,
timestamp de 48 kHz u32, nivel dBov i8, banderas u8— seguida de una carga
Opus que el relay nunca inspecciona. Los niveles inválidos se recortan en lugar de
descartar la trama: perder una métrica es mejor que perder audio.
Qué pasa exactamente cuando pulsas “Start huddle”
El botón está en la cabecera del canal, con icono de auriculares, y también aparece como elemento de menú. Al pulsarlo, el escritorio ejecuta cinco pasos en orden:
sequenceDiagram
participant D as Buzz Desktop
participant R as Relay
D->>R: 1. Crea un canal efímero, privado, tipo stream, con TTL de 3600 s
D->>R: 2. Publica las guidelines de voz como kind 48106 en ese canal
D->>R: 3. Añade a cada invitado con rol bot mediante kind 9000
D->>R: 4. Emite kind 48100 en el canal padre
R-->>D: Devuelve la información de conexión de audio
D->>R: 5. Abre el WebSocket de audio y entra a la sala
Detalles duros de esa secuencia:
- El canal efímero se llama
<nombre del canal> huddle, o en un DMAna <> Beto huddlecon los nombres de los participantes. Si no hay nombre utilizable, cae ahuddle-<8 primeros caracteres del uuid>. - Las guidelines se publican antes de añadir a los agentes, a propósito: los agentes se suscriben al recibir la notificación de membresía y podrían completar su carga inicial antes de que las guidelines existan.
- Los invitados entran con rol
bot, hasta un máximo de 20 agentes por huddle, con los pubkeys deduplicados y validados antes de tocar el relay. - Si cualquier paso falla, el canal efímero huérfano se archiva y el estado vuelve a inactivo.
Quien llega después ve un indicador con “Join huddle” y el número de participantes, y al entrar el canal efímero aparece en su barra lateral.
Capacidad: tope blando de 25 pares por sala —25 pares son 600 copias de
trama cada 20 ms— y tope duro de 255, porque el índice de par es un u8.
Los controles que existen
La barra de huddle del escritorio tiene micrófono y silencio, con modo de voz configurable y selección de dispositivo de entrada; Start transcript / Stop transcript; reacciones de emoji; Add agent to huddle, que mete un agente a la conversación en curso; Open huddle in a new window y Return huddle to drawer; Leave huddle; y la lista de participantes con indicador de quién está hablando.
Las reacciones de emoji del huddle viajan como evento efímero kind 24810: se ven en el momento y nunca entran al timeline del canal. Son un gesto, no un registro.
Transcripción y voz de los agentes
Dos piezas locales, ambas opcionales:
Transcripción (STT). Se activa desde la barra. Si el modelo aún no está descargado, arranca sola cuando la descarga termina. Al desactivarla, la tubería se desmonta de inmediato y cualquier tarea en vuelo se invalida antes de publicar otro segmento.
Voz de los agentes (TTS). Aquí entra buzz-voice. Pocket TTS emite PCM mono a
24 kHz y la voz de referencia que viene de fábrica se llama reference_sample.
Puedes importar las tuyas como WAV, con estos límites: entre 2 y 30 segundos de
duración, frecuencia de muestreo entre 8 y 96 kHz, archivo de origen de hasta
25 MB, y almacenamiento canónico a 32 kHz.
Las voces importadas se guardan en tu dispositivo, en un registro local con hash de contenido. No viajan al relay.
Los agentes que entran a un huddle traen su propio STT y TTS y se conectan al mismo relay de audio que las personas. Las guidelines kind 48106 que reciben al entrar les indican, entre otras cosas: responder de inmediato, enviar una frase por mensaje en lugar de componer la respuesta completa, no usar Markdown ni listas, y callarse si no se les ha hablado. La razón de la frase por mensaje es de latencia pura: el escritorio va leyendo cada mensaje en voz alta según llega, en orden, así que la primera frase enviada es la que rompe el silencio.
El ciclo de vida, y por qué está “being wired up”
Los eventos que rodean un huddle:
| Kind | Quién lo emite | Qué marca |
|---|---|---|
| 48100 | El cliente de escritorio que inicia | Huddle iniciado, publicado en el canal padre |
| 48106 | El cliente de escritorio | Guidelines de voz, en el canal efímero |
| 48101 | El relay, al autenticarse el WebSocket de audio | Un participante entró |
| 48102 | El relay, al desconectarse el WebSocket | Un participante salió |
| 48103 | El relay al vaciarse la sala, o el cliente que cierra el huddle | El huddle terminó, en el canal padre |
| 24810 | El cliente | Ráfaga de reacción emoji, efímera |
stateDiagram-v2
[*] --> Creando: pulsas Start huddle
Creando --> Conectado: canal efímero listo y audio abierto
Creando --> Inactivo: fallo en cualquier paso, se archiva el canal
Conectado --> Conectado: entran y salen pares, kinds 48101 y 48102
Conectado --> Terminado: sale el último par
Terminado --> [*]: el canal se archiva y se emite kind 48103
Inactivo --> [*]
Cuando sale el último par, la sala se cierra y el canal efímero se archiva de forma atómica junto con la emisión del 48103. Si el archivado falla, el huddle sigue vivo en lugar de quedar en un estado inconsistente.
La advertencia, que es el motivo de esta sección: el README marca “Huddle lifecycle events” en la columna 🚧 “Being wired up”. Los eventos se emiten —eso está en el código del relay y del escritorio— pero la superficie que los consume sigue asentándose. No construyas todavía un proceso que dependa de recibir un 48103 puntual para cerrar una tarea, ni un informe que cuente participaciones a partir de 48101 y 48102. Trátalos como señales de interfaz, no como contabilidad.
Y una cosa que directamente no existe: grabación y publicación por pista. No es que esté a medio cablear — es que en el registro de kinds del proyecto no hay ningún kind de grabación, reservado ni de otro tipo, y por tanto tampoco hay productor. Un huddle no se graba: si necesitas registro de lo hablado, la transcripción es tu única vía y hay que activarla explícitamente durante la sesión.
Errores que te vas a encontrar
| Lo que ves | Qué está pasando |
|---|---|
| La subida de una foto del teléfono es rechazada | La imagen lleva EXIF u otros metadatos. Sube desde la aplicación o desde buzz upload file, que limpian antes. |
| Un SVG no se sube | Está en la lista de denegados: es un vector clásico de XSS almacenado. |
| El video no sube desde el escritorio | Falta ffmpeg en el sistema. El escritorio lo necesita para normalizar el archivo y extraer el póster. |
buzz upload file dice unsupported file type con un PDF | El CLI solo admite JPEG, PNG, GIF, WebP y MP4, aunque el relay acepte más por el camino genérico. |
| El comentario del video no aparece en la línea de tiempo | El cuerpo no empieza con el timecode entre corchetes, o el timecode cae más allá de la duración del video. |
| No ves el botón de editar el canvas | No tienes rol de gestión en el canal, no eres miembro, es un DM, o el canal está archivado. |
| ”cannot start huddle: already in phase …” | Ya hay un huddle activo o iniciándose en esta instancia del escritorio. |
Todo lo que tenga que ver con cambiar los límites de tamaño, el backend S3 o el almacenamiento del relay es trabajo de operador y está en el curso de administrador.
Resumen
- Los medios viven en un almacén Blossom direccionado por contenido: la clave es
el
sha256, subir dos veces el mismo archivo devuelve el mismo descriptor y la URL nunca contiene el nombre del fichero. - Límites por defecto: 50 MB imagen, 10 MB GIF, 500 MB video, 100 MB archivo genérico y 25 megapíxeles por imagen.
- El tipo se deduce de los magic bytes, nunca del
Content-Type. HTML, JS, SVG y ejecutables están prohibidos; los genéricos siempre se sirven como descarga. Y el relay rechaza imágenes con metadatos incrustados: los limpia el cliente. - La referencia queda en un tag
imetade NIP-92 más una línea Markdowno. Miniatura de 320 px y blurhash los genera el servidor. Desde la terminal:buzz upload file,buzz media getybuzz messages send --file. - Un comentario anclado a un frame es una respuesta de hilo cuyo cuerpo empieza
con
[MM:SS]. Nada más. Por eso funciona igual desde el escritorio que desde el CLI o desde un agente. - El canvas es un documento Markdown único por canal, kind 40100, que se reemplaza entero. Lo editan quienes gestionan el canal; los DMs no tienen canvas. No es una pizarra colaborativa.
- Los huddles corren dentro del propio relay por WebSocket con tramas Opus, sin SFU. Iniciar uno crea un canal efímero privado con TTL de 3600 s, publica las guidelines de voz y emite el aviso al canal padre. Tope de 25 pares, 20 agentes.
- El crate
buzz-voiceno es el transporte: es el TTS local del escritorio —Pocket TTS a 24 kHz— y las voces de referencia importadas, que se quedan en tu máquina. - Los eventos de ciclo de vida de huddle están en la columna 🚧 del README: se emiten, pero no apoyes automatizaciones sobre ellos todavía. La grabación no existe en ninguna forma: no hay kind reservado para ella ni productor.
Siguiente: Búsqueda: encontrar la conversación, el patch y la decisión