read-video —— Lee vídeos "por bytes bajo demanda"
¿Qué problema resuelve esta habilidad?
El usuario envía un enlace de vídeo y quiere entender su contenido. El enfoque ingenuo es descargar el vídeo completo para procesarlo: es lento, ocupa almacenamiento, tiene riesgos de derechos de autor y, a menudo, solo se necesitan unos segundos. Esta habilidad hace lo siguiente: toma solo los bytes cercanos al momento deseado. La pista de audio se transcribe localmente sin conexión (principal para responder "¿de qué trata?"), y la pista de vídeo se decodifica en fotogramas clave que se pasan a un modelo de visión para completar la información visual.
Benchmark real (MP4 de 249 MB / 9 min 56 s, sin faststart):
| Acción | Bytes del servidor | % del total |
|---|---|---|
| Tomar fotograma en segundo 30 | 2.0 MB | 0.839% |
| Tomar fotograma en minuto 8 | 1.7 MB | 0.707% |
| Tomar 20 segundos de audio desde 480 s | 6.6 MB | 2.784% |
Cuándo usar / Cuándo no usar
Usar: El usuario proporciona un enlace directo de vídeo o un enlace de página que contiene vídeo (plataformas como Bilibili son resueltas automáticamente por resolve.py, sin necesidad de llamadas API manuales).
No usar: Archivos locales (léelos directamente), vídeos largos que requieran comprensión completa segundo a segundo (el coste sigue siendo alto; pide primero al usuario los puntos de tiempo).
Principios estrictos
- No descargar el vídeo completo. En ningún paso uses
wget/curl -Opara el archivo completo. - Primero detectar, luego actuar. No hacer seek sin detectar puede provocar una espera inútil si el servidor no soporta Range.
-ssdebe ir antes de-i. Si va después, es seek del lado de salida, equivalente a decodificar secuencialmente hasta el punto de destino, degradándose a traer todo el archivo. Delante es seek del lado de entrada, el demuxer salta directamente.- Medición activa.
--measureactivo por defecto: usa un proxy local de streaming para contar los bytes del servidor, es la única evidencia sólida de que realmente se tomaron solo los bytes necesarios y prueba para el usuario de que no se descargó el vídeo completo. - Los enlaces de página se pasan a los scripts para resolver. Una URL de página no es un enlace directo, y los enlaces directos tienen firmas que caducan. Usa
resolve.py, o pasa directamente el enlace agrab.py/transcribe.py(ellos lo resuelven automáticamente y añaden las cabeceras necesarias). No escribas llamadas API del sitio en la conversación: ese código siempre es diferente y no ha sido revisado.
Flujo de trabajo
Paso 0: Distinguir entrada — enlace de página o enlace directo
- Enlace de página (Bilibili, YouTube, TikTok, Weibo, Vimeo… más de 1700 sitios) → primero resolver a enlace directo. También puedes pasar el enlace directamente a
grab.py/transcribe.py, que lo reconocen y lo resuelven automáticamente añadiendo las cabeceras necesarias. - Enlace directo (extensión
.mp4/.m3u8u otra de medio) → se puede saltar la resolución.
La regla de decisión está en resolvers.is_page_url(): cualquier URL con extensión de medio se trata como enlace directo; el resto se envía a la capa de resolución.
Primer paso: Obtener un flujo accesible
python scripts/resolve.py "<enlace_de_página>"
Devuelve video_url (para fotogramas), audio_url (para transcripción, diferente del anterior), headers (cabeceras requeridas por la CDN), además del título, duración y un conjunto de puntos de tiempo sugeridos para extraer fotogramas.
La línea Plataforma indica el backend utilizado: native (adaptador propio, actualmente Bilibili) o yt-dlp (genérico, más de 1700 sitios). Para forzar, usa --backend native|yt-dlp|auto.
Comando completo en un solo paso (resolución y extracción enlazadas automáticamente, recomendado):
python scripts/transcribe.py --url "<enlace_de_página>" --seconds 90
python scripts/grab.py "<enlace_de_página>" --times 14 29 44 58 73 --prefix shot --outdir ./out
Segundo paso: Detectar (recomendado para enlaces directos, opcional para enlaces de página)
python scripts/probe.py <enlace_directo> --json
Si el enlace directo proviene de una CDN, incluye la fuente: --header 'Referer: https://www.bilibili.com'.
Observa tres campos:
seekable— si esfalse, detente. El servidor ignora Range, y la extracción puntual de fotogramas degenera en una descarga secuencial. Cambia a "descargar y decodificar" (-i <url>sin-ss, toma el fotograma más temprano del flujo) o informa al usuario de que no es posible.container.moov_offset/faststart— si el índice está al inicio (faststart), el coste fijo es de decenas de KB; si está al final, primero hay que tomar un segmento del final del archivo (medido ~441 KB). Es el coste fijo de "tomar un fotograma", inevitable.content_length— denominador para calcular el porcentaje del total.
Tercer paso: Extraer según la intención del usuario
El usuario da un punto de tiempo específico → extracción puntual:
python scripts/grab.py <url> --time 183.5 --out shot.jpg
El usuario no da punto de tiempo → extraer algunos fotogramas para tener una idea general:
python scripts/grab.py <url> --times 5 60 180 420 --prefix shot --outdir /tmp/rv
Consejo para seleccionar puntos: toma de 4 a 6 fotogramas según la densidad de contenido, no los distribuyas uniformemente (eso equivale a decodificar todo el vídeo). Si no estás seguro, toma 3 fotogramas primero, y luego decide si necesitas ver una sección en detalle.
Para entender de qué trata → transcribe primero (recomendado):
python scripts/transcribe.py --url <url> --segments 0:90 320:90 630:97
Un solo comando que "corta audio → reconoce localmente", devuelve texto con marcas de tiempo, sin necesidad de API externa ni inferencia en la nube. La primera ejecución descarga automáticamente el modelo SenseVoice (~240 MB, desde hf-mirror, solo una vez).
Para vídeos largos, toma de 3 a 4 segmentos de 90 segundos cada uno para cubrir la línea principal, no transcribas todo el vídeo — es mucho más rápido que analizar fotograma por fotograma y más adecuado para responder "¿de qué trata?". Primero transcribe, luego usa fotogramas para complementar la información visual — ese es el orden.
Después de transcribir, revisa la tasa de producción. En locución normal, aproximadamente 4-6 caracteres/segundo; la salida del script incluye chars_per_second. Si es inferior a 1 carácter/segundo, el script avisará activamente. En ese caso (música pura, gameplay, demostración silenciosa) no tomes ese texto como contenido, sino cambia a una extracción más densa de fotogramas (8-10) para análisis visual — el audio no tiene información, la respuesta solo está en las imágenes.
Si solo necesitas el archivo de audio (por ejemplo, para enviarlo a otro lugar para transcripción):
python scripts/grab.py <url> --start 0 --seconds 60 --audio clip.wav
Cuando haya pistas de subtítulos incrustadas, prioriza leer los subtítulos (ffmpeg -i <url> para ver si hay flujo subtitle), son más precisos y rápidos que el ASR.
Cuarto paso: Pasar los fotogramas al modelo de visión
grab.py solo obtiene los píxeles localmente, no entiende el contenido. Pasa las rutas de los jpg generados al modelo de visión para hacer preguntas ("¿qué hay en este fotograma?", "¿de qué trata esta sección?"). Si son varios fotogramas, entrégalos en orden temporal para que el modelo los ensarte en una narrativa.
Cómo leer la salida
Salida JSON de grab.py, cada acción con un registro de bytes:
{
"ok": true,
"actions": [{
"kind": "frame", "time": 30,
"out_bytes": 48211,
"upstream_bytes": 2097152,
"requests": 4,
"wall_seconds": 2.317
}],
"total_upstream_bytes": 2097152
}
upstream_bytes / content_lengthes el porcentaje del total. Si supera el 5%, detente y revisa: probablemente la posición de-ssestá mal, o el servidor no reconoce Range.requestsalto es normal: ffmpeg necesita varias solicitudes (moov, desde el fotograma clave hasta el objetivo, cabecera de audio).ok: falsemiraerr_tail; lo común es timeout y 5XX — el script ya tiene reintentos integrados; si aún falla, prueba con otro punto de tiempo.
Salida de transcribe.py:
{
"ok": true,
"text": "Esta es una tierra rodeada de grandes ríos y montañas...",
"segments": [{"start": 0, "end": 25, "text": "..."}],
"audio_seconds": 276.7,
"model": "SenseVoiceSmall-int8"
}
text es la concatenación de todos los segmentos; segments contiene marcas de tiempo, útil cuando se necesita saber "¿qué se dijo en el segundo X?".
Entorno
- Intérprete: Python del venv alojado localmente. En Windows, la ruta es similar a
~/.workbuddy/binaries/python/envs/default/Scripts/python.exe. Observa que esScripts/, no el estilo Unixbin/(escribirbin/pythoncomo en la documentación dará error de "archivo no encontrado"). - Dependencias:
httpx,imageio-ffmpeg(para extracción),sherpa-onnx,numpy(para transcripción),yt-dlp(resolución de plataformas genéricas); todo instalado en el mismo venv. Si falta alguna, el script lo indicará claramente. Sin yt-dlp, Bilibili sigue funcionando (con adaptador propio), pero otros sitios fallarán en la resolución. - ffmpeg: Viene con la construcción estática de
imageio-ffmpeg, no requiere instalación en el sistema. Para especificar otro, usa la variable de entornoRV_FFMPEGo--ffmpeg. - Caché del modelo ASR:
~/.workbuddy/cache/read-video/models(~240 MB, descarga automática en la primera ejecución). Si el archivo existe y el número de bytes coincide, se salta la descarga.
Límites conocidos
- La CDN a menudo verifica el origen: La CDN de vídeos de sitios como Bilibili requiere
Referer; sin él, da 403. Usarresolve.pyo--pagelo añade automáticamente; al pasar enlaces directos manualmente, recuerda--header 'Referer: https://www.bilibili.com'(los tres scripts lo soportan, se puede repetir). - Los enlaces directos caducan: Los enlaces directos de los sitios llevan firma y fecha de caducidad; úsalos dentro de la misma tanda de operaciones. Si aparece un 403, vuelve a ejecutar
resolve.py. - Cobertura de plataformas = cobertura de yt-dlp (más de 1700 sitios), no mantenida por separado. Si un sitio no se resuelve, primero usa
--backend yt-dlppara ver el error original; los sitios que requieren inicio de sesión / Cookie fallarán; en ese caso, abandona o proporciona las cookies manualmente. yt-dlp necesita actualizarse periódicamente (las versiones antiguas fallan cuando los sitios cambian):pip install -U yt-dlp. - "Resuelve" no significa "se obtiene": La capa de resolución solo proporciona la URL. Si la extracción falla, primero distingue si es un fallo de resolución o de red: en pruebas, la conexión local a YouTube / Vimeo falló (proxy 502), mientras que TikTok / Weibo / Xigua Video eran accesibles.
- HLS / m3u8: No es un solo archivo, el análisis de caja de
probe.pyfallará (tendrá un campoerror). En ese caso,grab.pysigue funcionando; ffmpeg extraerá los segmentos necesarios por sí mismo;content_lengthestará vacío, por lo que no se puede calcular el porcentaje. - DASH con audio y vídeo separados: Muchos sitios (incluido Bilibili) tienen flujos de vídeo y audio como dos URL independientes. Usa el flujo de vídeo para fotogramas y el de audio para transcripción; si los usas al revés, obtendrás silenciosamente un resultado vacío.
- La precisión de
-ssestá limitada por el GOP: Para tomar un fotograma en un segundo específico, debes decodificar desde el fotograma clave más cercano, por lo que la unidad mínima de datos es "desde el fotograma clave hasta el fotograma objetivo", no un rango de un solo byte. - Homófonos en ASR: SenseVoiceSmall funciona bien con chino, pero los nombres de personas, lugares y palabras antiguas suelen tener errores de homófonos (medido: "阪泉之野" → "版圈之也", "涿鹿" → "涿陆"). Corrige según el contexto antes de citar nombres propios, no tomes el texto transcrito como un hecho.
- Dependencias:
httpx+imageio-ffmpeg(extracción) +sherpa-onnx+numpy(transcripción). Si falta alguna, el script dará un error claro indicando cuál instalar.
Para más detalles sobre el principio, problemas encontrados y datos de prueba, consulta references/methodology.md.
La relación entre esta habilidad y el plugin dsh dsh-readvideo se describe en references/dsh-dual-form.md.