Grabador de pantalla en el navegador para soporte técnico. Le pasas una URL a tu cliente, entra, pulsa un botón, graba lo que le pasa y te devuelve un enlace que te pega en el email. Sin extensiones, sin instalar nada, sin registrarse.
Pensado para agencias y equipos de soporte que hoy le dicen al cliente «grábamelo con Loom» y se topan con la extensión, la cuenta y los límites del plan gratuito.
Auto-alojado: los vídeos van a tu MinIO o S3 y se borran solos a los 60 días.
Cliente → captura.hormi.link → [Grabar] → sube a tu MinIO → enlace → tu email
- Graba la pantalla desde el navegador, con
getDisplayMediayMediaRecorder. Sin extensión ni instalación: son APIs estándar de Chrome, Edge, Firefox y Safari. - Micrófono incluido, mezclado con el audio de la web en una sola pista, para que el cliente pueda explicar el problema mientras lo enseña.
- Sube mientras graba. Al pulsar «Parar» casi todo está ya en el servidor, así que el enlace aparece en segundos en vez de tras una espera larga. Si se corta la conexión a mitad, cada trozo se reintenta con espera creciente. (En Safari no: ver «Formato» más abajo.)
- Avisa si no se está capturando nada, en vez de dejar al cliente grabar diez minutos de vídeo vacío y descubrirlo al final.
- Revisar antes de enviar. El cliente ve su grabación y decide: enviar o descartar. Al descartar, la subida se aborta y no queda nada en el almacén.
- Ruta alternativa en móvil. iOS y Android no dejan que una web grabe la pantalla; la app lo detecta y muestra las instrucciones del propio dispositivo y un formulario de subida.
- Retención de 60 días aplicada por el propio MinIO, y avisada en pantalla.
- Personalizable: nombre, logo (con variante para modo oscuro), color, email de soporte y un aviso libre, todo por variables de entorno.
- No crea tickets ni se integra con ningún helpdesk. El resultado es un enlace, y eso cubre también a quien todavía no es cliente y sólo quiere pedir presupuesto.
- No hay cuentas, ni login, ni base de datos. Los metadatos viven en un
meta.jsonjunto al vídeo, en el mismo bucket. - No edita, ni recorta, ni transcribe.
Conviene conocerlos antes de desplegar; ninguno es un fallo, son restricciones de los navegadores:
| Situación | Qué pasa |
|---|---|
| iPhone / iPad | getDisplayMedia no existe en iOS, ni en Chrome para iOS (que es Safari por dentro). La app detecta y ofrece subir un vídeo grabado con la función nativa. |
| Android | Soporte irregular. Mismo camino alternativo. |
| Audio del sistema en Mac | Sólo se captura al compartir una pestaña de Chrome, no la pantalla completa. Las instrucciones lo dicen explícitamente. |
| Safari en escritorio | Graba, pero no deja elegir una pestaña concreta: sólo ventana o pantalla. Genera MP4. |
| Firefox | Graba en WebM. No admite capturar el audio del sistema; el micrófono sí. |
| Formato | Se prefiere WebM, y no por calidad: en Chrome, MediaRecorder con MP4 ignora el timeslice y no entrega un solo byte hasta que se llama a stop() (medido: 12 s de grabación, primer y único trozo a los 12.295 ms). Con MP4 no hay subida progresiva. MP4 queda como último recurso para Safari, que no sabe grabar WebM; allí todo se sube al parar. |
| Reproducir en Safari | Un WebM grabado en Chrome no se reproduce en Safari. La página del enlace lo detecta y ofrece descargar el archivo, que está intacto. |
| HTTPS obligatorio | Sin conexión segura el navegador no da acceso a la pantalla. En localhost sí funciona. |
Necesitas un bucket, la carpeta de las grabaciones, la regla de retención y un usuario con permiso sólo sobre esa carpeta.
¿La consola web de MinIO no carga? Es lo habitual detrás de un proxy. Sigue docs/minio-por-ssh.md: la guía completa paso a paso desde el servidor, con comprobación de que el permiso queda bien acotado.
Si tienes acceso a la máquina donde está el repo, el script lo hace todo de una vez:
MINIO_URL=https://minio.tu-servidor.com \
MINIO_ROOT_USER=admin \
MINIO_ROOT_PASSWORD=tu-clave \
./scripts/preparar-minio.shAl terminar imprime las variables de entorno listas para pegar. Guarda
S3_SECRET_KEY en ese momento: no se puede volver a consultar.
Hacerlo a mano, desde la consola de MinIO
Bucket: créalo con el nombre vistazo y déjalo privado. Los vídeos nunca se
sirven directamente desde MinIO: pasan por la app, para poder caducarlos y cambiar de
almacén sin romper los enlaces ya enviados.
Regla de ciclo de vida (Buckets → vistazo → Lifecycle → Add Rule), o por API:
{
"Rules": [
{
"ID": "borrar-grabaciones-60-dias",
"Status": "Enabled",
"Filter": { "Prefix": "grabaciones/" },
"Expiration": { "Days": 60 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
}
]
}Expiration.Days cuenta desde la fecha de creación del objeto, que es justo lo que
queremos. AbortIncompleteMultipartUpload limpia las subidas que se quedaron a medias
y que, si no, ocupan espacio invisible.
Usuario y política (Identity → Users → Create). Política mínima:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject", "s3:GetObject", "s3:DeleteObject",
"s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"
],
"Resource": ["arn:aws:s3:::vistazo/grabaciones/*"]
}
]
}La retención se configura en dos sitios y deben coincidir. MinIO es quien borra de verdad;
RETENTION_DAYSsólo controla la fecha que se le enseña al cliente. Si cambias una, cambia la otra.
- Project → + Service → App. Nómbralo
vistazo. - Source:
GitHub→ repositoriodhaula/vistazo, ramamain. - Build:
Dockerfile(Easypanel detecta elDockerfilede la raíz). - Environment: pega el contenido de
.env.examplecon tus valores. Como mínimo:PUBLIC_URL,S3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY,S3_SECRET_KEY. - Deploy.
En Domains → Add Domain:
- Host:
captura.hormi.link - Puerto destino / Proxy Port: el mismo valor que hayas puesto en
PORT(en.env.exampleviene5200) - HTTPS: activado (Let's Encrypt)
⚠️ El puerto destino es el error más habitual. Easypanel propone80por defecto. Si lo dejas así, el contenedor arranca bien pero el dominio responde 502.
Sobre los puertos y otras apps: cada servicio de Easypanel corre en su propio
contenedor, con su propia red. Que otra app use el 3000 no impide que Vistazo también
lo use: no hay conflicto salvo que publiques puertos del anfitrión a mano. Aun así,
.env.example trae PORT=5200 para que cada app tenga un número distinto y sea más
fácil de seguir de un vistazo.
Apunta el DNS de tu dominio al servidor antes de emitir el certificado.
curl https://captura.hormi.link/healthzDebe responder {"ok":true,"bucket":"vistazo",...}. Después entra con el navegador y
haz una grabación de diez segundos de prueba.
| Variable | Por defecto | Para qué |
|---|---|---|
PUBLIC_URL |
(se deduce de las cabeceras) | URL con la que se construyen los enlaces. Sin barra final. |
PORT |
3000 |
Puerto de escucha. Debe coincidir con el puerto destino del dominio. |
S3_ENDPOINT |
— obligatoria | URL de MinIO o S3. |
S3_BUCKET |
— obligatoria | Nombre del bucket. |
S3_ACCESS_KEY / S3_SECRET_KEY |
— obligatorias | Credenciales. |
S3_PREFIX |
grabaciones/ |
Carpeta. Debe coincidir con la regla de retención. |
S3_FORCE_PATH_STYLE |
true |
Déjalo en true para MinIO. |
S3_REGION |
us-east-1 |
MinIO la ignora. |
RETENTION_DAYS |
60 |
Sólo informativo: quien borra es MinIO. |
MAX_DURATION_MIN |
15 |
La grabación se para sola al llegar. |
MAX_SIZE_MB |
600 |
Ídem, por tamaño. |
VIDEO_KBPS / AUDIO_KBPS |
2500 / 128 |
Calidad. Subir sube el peso. |
FRAME_RATE |
15 |
Suficiente para enseñar una web. |
PART_SIZE_MB |
8 |
Tamaño de cada trozo. Mínimo 5 (lo exige S3). |
UPLOADS_PER_HOUR_PER_IP |
20 |
Freno contra abuso casual. |
UPLOADS_PER_HOUR_TOTAL |
60 |
Tope global de grabaciones nuevas por hora, sumando todas las IP. Acota cuánto disco puede llenarse. |
ORG_NAME |
Soporte |
Se muestra en la cabecera. |
BRAND_LOGO_URL |
(vacío) | Ruta o URL del logo. Si está vacío, se muestra ORG_NAME en texto. |
BRAND_LOGO_URL_DARK |
(vacío) | Variante para modo oscuro, si el logo principal no se ve sobre fondo oscuro. |
BRAND_COLOR |
#198994 |
Color de rellenos y botones. |
BRAND_COLOR_TEXT |
#0E6C75 |
Variante para texto (necesita más contraste). |
SUPPORT_EMAIL |
(vacío) | Si lo pones, aparece un botón «Abrir mi email con el enlace». |
CUSTOM_NOTICE |
(vacío) | Frase libre en el pie. |
ORG_URL |
(vacío) | Enlace del crédito: «Hecho con Vistazo de Tu Nombre». |
FOOTER_LINKS |
(vacío) | Menú del pie. Ver abajo. |
LOG_LEVEL |
info |
warn en producción si no quieres ruido. |
FOOTER_LINKS define los enlaces que aparecen arriba del pie, en las dos páginas
(la de grabar y la del enlace). Entradas separadas por ;, y dentro Texto|URL:
FOOTER_LINKS=Política de privacidad|https://ejemplo.com/privacidad ; Aviso legal|https://ejemplo.com/aviso-legal ; Contacto|mailto:hola@ejemplo.com
Se aceptan http, https, mailto y rutas que empiecen por /. Cualquier otro
esquema —javascript:, data:— se descarta al arrancar. Máximo 8 entradas.
Los enlaces se construyen en el navegador con textContent, nunca con innerHTML.
Vistazo no usa cookies, ni almacenamiento local, ni analítica, ni ningún recurso
de terceros; la política de seguridad de contenido bloquea las peticiones externas.
No hay nada que consentir, así que no lleva banner de cookies. Aun así, se graba la
pantalla de una persona, así que conviene enlazar tu política de privacidad con
FOOTER_LINKS y explicar allí qué hacéis con las grabaciones y cuánto duran.
npm install
cp .env.example .env # y rellena los valores
npm run devCon Docker y un MinIO de pruebas incluido:
docker compose up --buildLevanta Vistazo en localhost:3000, MinIO en localhost:9000 y su consola en
localhost:9001 (minioadmin / minioadmin), con el bucket y la retención ya
configurados.
npm testLevanta un S3 falso en memoria y recorre el ciclo real: abrir grabación, subir por trozos, cerrar, leer la ficha y reproducir entera y por rangos. También comprueba que el token protege la subida, que descartar aborta el multipart y que se respetan los límites de tamaño y de formato.
Navegador Servidor MinIO
───────── ──────── ─────
getDisplayMedia + getUserMedia
│
├─ mezcla audio (AudioContext)
├─ MediaRecorder, trozos de 2 s
│
├─ POST /api/uploads ──────────► CreateMultipartUpload ────► uploadId
│ ◄──── { id, token }
│
├─ acumula hasta 8 MiB
├─ PUT .../parts/1 ────────────► UploadPart ───────────────► ETag
├─ PUT .../parts/2 ────────────► UploadPart ───────────────► ETag
│ (mientras sigue grabando)
│
├─ [Parar] → vista previa local
├─ [Enviar]
├─ POST .../complete ──────────► CompleteMultipartUpload
│ PutObject meta.json
│ ◄──── { url: /v/<id> }
└─ enlace para el email
Detalles que importan:
- El bucket es privado.
/api/v/:id/streamhace de intermediario y respeta las peticionesRange, que es lo que usa el<video>para saltar por la barra de tiempo. - Los identificadores son de 16 caracteres aleatorios de un alfabeto sin caracteres ambiguos. No se pueden adivinar y se pueden dictar por teléfono.
- El estado de las subidas en curso vive en memoria, a propósito: una grabación a medias no vale nada si el contenedor se reinicia, y así no hace falta base de datos. Un barrendero interno aborta las abandonadas a las 6 horas.
- Sin enlaces firmados. Una URL prefirmada de S3 dura como mucho 7 días, así que no sirve para una retención de 60. Por eso el vídeo se sirve a través de la app.
- La duración del WebM se recalcula en el reproductor. El WebM de
MediaRecorderno lleva la duración en el contenedor y el navegador la da como infinita, lo que deja la barra de tiempo inservible. La página del enlace fuerza un salto muy lejano para que el navegador la calcule y vuelve al principio. Es un apaño conocido, pero evita tener que reprocesar cada vídeo con ffmpeg en el servidor.
Tal cual está, una sola réplica. El registro de subidas en curso es un Map en
memoria: con dos réplicas detrás de un balanceador, los trozos de una misma grabación
irían a instancias distintas y el uploadId no se encontraría. Para escalar
horizontalmente habría que mover ese registro a Redis, o activar sesiones pegajosas en
el proxy. Para el uso al que está pensado (unas cuantas grabaciones al día), una
réplica sobra.
Estás grabando la pantalla de otra persona, y ahí puede aparecer información suya o de terceros.
No se pueden listar las grabaciones. No existe ningún endpoint que devuelva un
listado: para ver una hay que conocer su enlace. El usuario de MinIO que usa la app
tampoco tiene permiso s3:ListBucket, así que ni robando sus credenciales se podría
enumerar el bucket. Los identificadores son 16 caracteres de un alfabeto de 56, unos
93 bits: no se adivinan por fuerza bruta.
No se indexa nada. Las páginas llevan <meta name="robots" content="noindex">,
hay un robots.txt que prohíbe todo el sitio, y todas las respuestas —incluido el
propio vídeo— llevan la cabecera X-Robots-Tag: noindex, nofollow, noarchive. Esto
último es lo que importa: un archivo de vídeo no puede llevar etiquetas HTML, y el
enlace puede acabar pegado en cualquier sitio público.
El resto:
- Los vídeos se borran solos a los 60 días, avisado en pantalla y en la página del enlace.
- El bucket es privado; el vídeo sólo se sirve a través de la app.
- El token que autoriza subir trozos se compara en tiempo constante.
Referrer-Policy: no-referrer, para que el identificador no viaje en la cabeceraReferersi el vídeo enlaza a algún sitio.frame-ancestors 'none': la página no se puede incrustar en otra./healthzsólo dice{"ok":true}; no publica el bucket ni el prefijo.- Se guarda el
User-Agenten elmeta.jsonpara depurar, y no se expone en la API. - Sin cookies, sin analítica, sin recursos de terceros.
Todo lo anterior está cubierto por pruebas automáticas (npm test).
La URL es pública y cualquiera puede subir. Los topes por IP y global acotan el peor
caso, pero alguien decidido puede llenar el disco: con los valores por defecto,
hasta 60 grabaciones por hora. Si eso preocupa, baja UPLOADS_PER_HOUR_TOTAL y
MAX_SIZE_MB, y vigila el espacio del bucket. Un código de acceso en la URL sería la
solución completa, pero cerraría la puerta a quien todavía no es cliente y sólo quiere
enseñar un problema para pedir presupuesto.
MIT. Ver LICENSE.