read-video —— Leia Vídeos por Demanda de Bytes
O que este skill resolve
O usuário envia um link de vídeo e quer entender o conteúdo. A abordagem ingênua é baixar o vídeo inteiro para processá-lo — lento, ocupa armazenamento, apresenta riscos de direitos autorais e, muitas vezes, apenas alguns segundos são necessários. Este skill faz o seguinte: busca apenas o pequeno trecho de bytes próximo ao momento desejado. A faixa de áudio é transcrita offline localmente (principal recurso para responder "sobre o que é"), e os quadros de vídeo são extraídos como keyframes e enviados a um modelo de visão para complementar a informação visual.
Referência real (MP4 de 249 MB / 9 min 56 s, sem faststart):
| Ação | Bytes obtidos | % do total |
|---|---|---|
| Quadro no segundo 30 | 2,0 MB | 0,839% |
| Quadro no minuto 8 | 1,7 MB | 0,707% |
| Áudio de 20 s a partir de 480 s | 6,6 MB | 2,784% |
Quando usar / Quando não usar
Use: quando o usuário fornecer um link direto de vídeo ou um link de página contendo vídeo (plataformas como Bilibili são resolvidas automaticamente por resolve.py, sem necessidade de chamadas manuais de API).
Não use: para arquivos locais (leia diretamente) ou para vídeos longos que precisam de compreensão completa segundo a segundo (o custo ainda é alto; pergunte primeiro ao usuário o ponto no tempo).
Princípios rígidos
- Não baixe o arquivo inteiro. Em nenhuma etapa use
wget/curl -Opara o arquivo completo. - Sonde antes de agir. Sem sondagem, o seek em servidores que não suportam Range resultará em espera inútil.
-ssdeve vir antes de-i. Colocar depois é seek do lado da saída, o que equivale a decodificar sequencialmente até o ponto alvo e depois descartar, degenerando para puxar o vídeo inteiro. Colocar antes é seek do lado da entrada — o demuxer pula diretamente.- Mantenha a medição ativa.
--measureestá ativo por padrão: ele usa um proxy de streaming local para contar os bytes do upstream, sendo a única evidência concreta de que "realmente só pegou o necessário" e a prova para o usuário de que não baixou escondido o arquivo inteiro. - Links de página devem ser resolvidos por script. URL de página ≠ link direto, e links diretos possuem assinaturas que expiram. Use
resolve.py, ou passe o link diretamente paragrab.py/transcribe.py(eles resolvem automaticamente e adicionam os cabeçalhos necessários). Não escreva chamadas de API do site na conversa — esse código sempre fica diferente a cada vez e nunca foi revisado.
Fluxo de trabalho
Passo 0: Distinguir entrada — link de página ou link direto
- Link de página (Bilibili, YouTube, Douyin, Weibo, Vimeo… mais de 1700 sites) → resolva primeiro para link direto. Também pode passar o link diretamente para
grab.py/transcribe.py, que reconhecem e resolvem automaticamente, incluindo os cabeçalhos necessários. - Link direto (extensões de mídia como
.mp4/.m3u8) → pode pular a resolução.
A regra está em resolvers.is_page_url(): links com extensão de mídia são tratados como diretos; todos os demais links HTTP são enviados para a camada de resolução.
Primeiro passo: Obter o stream acessível
python scripts/resolve.py "<link da página>"
A saída inclui video_url (para extrair quadros), audio_url (para transcrição, diferente do anterior), headers (cabeçalhos exigidos pela CDN), além do título, duração e um conjunto de pontos de tempo sugeridos para amostragem.
A linha Plataforma indicará qual backend foi usado: native (adaptador personalizado, atualmente Bilibili) ou yt-dlp (genérico, 1700+ sites). Para forçar, use --backend native|yt-dlp|auto.
Comando único para todo o percurso (resolução e extração encadeadas automaticamente, recomendado):
python scripts/transcribe.py --url "<link da página>" --seconds 90
python scripts/grab.py "<link da página>" --times 14 29 44 58 73 --prefix shot --outdir ./out
Segundo passo: Sondagem (recomendado para links diretos; pode pular para links de página)
python scripts/probe.py <link direto> --json
Se o link direto for de uma CDN, inclua a origem: --header 'Referer: https://www.bilibili.com'.
Observe três campos:
seekable— sefalse, pare. O servidor ignora Range, e a extração pontual degenera para um pull sequencial. Mude para "baixar e decodificar" (-i <url>sem-ss, pegando o quadro mais à frente do fluxo) ou informe ao usuário que não é possível.container.moov_offset/faststart— se o índice está no início (faststart), o custo fixo é de algumas dezenas de KB; se está no final, é necessário buscar um trecho do final do arquivo (medido em cerca de 441 KB). Esse é o custo fixo para "pegar um quadro", inevitável.content_length— denominador para calcular "% do arquivo total" posteriormente.
Terceiro passo: Extrair dados conforme a intenção do usuário
Usuário forneceu um ponto específico → extração pontual:
python scripts/grab.py <url> --time 183.5 --out shot.jpg
Usuário não forneceu ponto → extraia alguns quadros para ter uma ideia geral:
python scripts/grab.py <url> --times 5 60 180 420 --prefix shot --outdir /tmp/rv
Dica para seleção de pontos: pegue 4 a 6 quadros baseados na densidade de conteúdo, não distribua uniformemente por todo o vídeo (isso equivaleria a decodificar o vídeo inteiro). Se não tiver certeza, pegue 3 quadros primeiro e decida depois se vale a pena examinar um trecho específico.
Para entender o que está sendo dito → transcreva diretamente (recomendado fazer primeiro):
python scripts/transcribe.py --url <url> --segments 0:90 320:90 630:97
Um único comando realiza "extrair áudio → reconhecer localmente", produzindo texto em chinês com timestamps, sem necessidade de API externa ou inferência na nuvem. Na primeira execução, baixa automaticamente o modelo SenseVoice (cerca de 240 MB, do hf-mirror, apenas uma vez).
Para vídeos longos, extraia 3 a 4 segmentos de 90 segundos cada para cobrir a linha principal; não é necessário transcrever o vídeo inteiro — isso é muito mais rápido que analisar quadro a quadro e mais adequado para responder perguntas como "sobre o que é". Primeiro transcreva o áudio, depois use quadros para complementar a informação visual — esta é a ordem.
Após a transcrição, verifique a taxa de produção. Uma narração normal produz cerca de 4 a 6 caracteres/segundo; a saída do script inclui chars_per_second. Se estiver abaixo de 1 caractere/segundo, o script alertará ativamente. Nesse caso (música pura, gameplay sem comentários, demonstração silenciosa) não use o pouco texto como conteúdo; mude diretamente para uma amostragem mais densa de quadros (8 a 10 quadros) para análise visual — não há informação no áudio, apenas nos quadros.
Quando precisar apenas do arquivo de áudio em si (por exemplo, para enviar a outro serviço de transcrição):
python scripts/grab.py <url> --start 0 --seconds 60 --audio clip.wav
Se houver faixa de legenda embutida, leia a legenda primeiro (ffmpeg -i <url> para ver se há fluxo subtitle), que é mais preciso e rápido que ASR.
Quarto passo: Enviar quadros para o modelo de visão
grab.py apenas obtém os pixels localmente; não entende o conteúdo. Passe o caminho do jpg de saída para o modelo de visão fazer perguntas ("O que há neste quadro?", "Do que trata este trecho?"). Para múltiplos quadros, envie-os em ordem cronológica para que o modelo os encadeie em uma narrativa.
Como interpretar a saída
grab.py produz JSON, cada ação com um 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_lengthé a "fração do arquivo total". Se ultrapassar 5%, pare e revise: provavelmente a posição-ssestá errada ou o servidor não aceita Range.requestsalto é normal: ffmpeg precisa fazer várias requisições (moov, do keyframe até o quadro alvo, cabeçalho de áudio).- Se
ok: false, vejaerr_tail; os erros comuns são timeout e 5XX — o script já tem retry embutido; se ainda falhar, tente outro ponto no tempo.
Saída de transcribe.py:
{
"ok": true,
"text": "Esta é uma terra cercada por grandes rios e montanhas...",
"segments": [{"start": 0, "end": 25, "text": "..."}],
"audio_seconds": 276.7,
"model": "SenseVoiceSmall-int8"
}
text é a concatenação de todos os segmentos; segments contém timestamps — consulte-o quando precisar saber "o que foi dito no segundo X".
Ambiente
- Interpretador: Python do venv gerenciado localmente. No Windows, o caminho é algo como
~/.workbuddy/binaries/python/envs/default/Scripts/python.exe. Observe que éScripts/, nãobin/como no Unix (escreverbin/pythonresultará em erro de arquivo não encontrado). - Dependências:
httpx,imageio-ffmpeg(extração),sherpa-onnx,numpy(transcrição),yt-dlp(resolução de plataformas genéricas) — todas instaladas no mesmo venv; se faltar alguma, o script reportará o erro claramente. Sem yt-dlp, o Bilibili ainda funciona (via adaptador nativo), mas outros sites falharão na resolução. - ffmpeg: Fornecido pela
imageio-ffmpegcomo build estático, sem necessidade de instalação no sistema. Para especificar outro, use a variável de ambienteRV_FFMPEGou--ffmpeg. - Cache do modelo ASR:
~/.workbuddy/cache/read-video/models(cerca de 240 MB, baixado automaticamente na primeira execução). Se o arquivo existir e o tamanho em bytes coincidir, o download é ignorado.
Limitações conhecidas
- CDN frequentemente verifica a origem: As CDNs de vídeo de sites como Bilibili exigem
Referer; sem ele, retornam 403. Usarresolve.pyou--pageadiciona automaticamente; ao passar um link direto manualmente, lembre-se de--header 'Referer: https://www.bilibili.com'(os três scripts suportam, pode repetir). - Links diretos expiram: Os links diretos dos sites têm assinaturas e prazos de validade; use dentro do mesmo lote de operações. Assim que receber 403, execute
resolve.pynovamente. - Cobertura de plataformas = cobertura do yt-dlp (1700+ sites), não mantida separadamente. Se um site não for resolvido, use
--backend yt-dlppara ver o erro bruto; sites que exigem login/Cookie falharão — nesse caso, desista ou forneça os cookies manualmente. O yt-dlp precisa ser atualizado periodicamente (versões antigas falham após mudanças nos sites) —pip install -U yt-dlp. - "Resolução bem-sucedida" ≠ "consegue extrair": A camada de resolução apenas fornece a URL. Se a extração falhar, primeiro distinga se é falha de resolução ou de rede — em testes, a máquina local não conseguia acessar YouTube/Vimeo (proxy 502), enquanto Douyin/Weibo/Xigua eram acessíveis.
- HLS / m3u8: Não é um único arquivo; a dissecação de boxes do
probe.pyfalhará (campoerrorpresente). Nesse caso,grab.pyainda funciona — o ffmpeg puxa os segmentos necessários por conta própria;content_lengthfica vazio, impossibilitando calcular a porcentagem. - DASH com áudio e vídeo separados: Muitos sites (Bilibili, etc.) fornecem URLs diferentes para fluxo de vídeo e áudio. Use o fluxo de vídeo para extrair quadros e o fluxo de áudio para transcrição — usar o errado resultará em resultado vazio silenciosamente.
- Precisão do
-sslimitada pelo GOP: Para extrair um quadro de um segundo específico, é necessário decodificar a partir do keyframe mais próximo; portanto, a unidade mínima de extração é "do keyframe até o quadro alvo", não "um intervalo de um byte". - Homófonos no ASR: O SenseVoiceSmall funciona bem para chinês, mas nomes de pessoas, lugares e palavras antigas costumam gerar erros de homofonia (exemplo real: "阪泉之野" → "版圈之也", "涿鹿" → "涿陆"). Corrija nomes próprios antes de citá-los; não trate o texto transcrito como fato.
- Dependências:
httpx+imageio-ffmpeg(extração) +sherpa-onnx+numpy(transcrição). Se faltar, o script reportará o erro e indicará qual instalar.
Para princípios mais detalhados, registro de problemas e dados de medição, consulte references/methodology.md.
A relação deste skill com o plugin dsh dsh-readvideo está em references/dsh-dual-form.md.