Racoon — Documentação Completa do Sistema

API multi-tenant de gerenciamento de mídia com armazenamento no Cloudflare R2, transcodificação de vídeo/áudio, transcrição (Whisper), sumarização por LLM, geração de thumbnail e entrega pública de mídia.

Esta é a referência de sistema. Para o guia enxuto por rota, veja CLIENT_API_CURL.md. Para a integração de features, veja integrations/feature-entitlements.md.


Índice

  1. Arquitetura
  2. Autenticação
  3. Modelo de dados
  4. Convenções e ambiente
  5. Upload de arquivos
  6. Consulta e entrega de mídia
  7. Listagem, picker, stats e lixeira
  8. Pastas (folders)
  9. Transcrição
  10. Sumarização (LLM)
  11. Features / entitlements
  12. Eventos em tempo real (SSE)
  13. Webhooks (workers → racoon)
  14. Rotas administrativas
  15. Conteúdo público (SYSTEM_PUBLIC)
  16. ★ Geração de thumbnail de vídeo
  17. Tabelas de referência

1. Arquitetura

O racoon é a API central (Fastify + TypeScript). Ele orquestra três workers externos por filas BullMQ (Redis) e persiste estado no PostgreSQL (Prisma). Os binários pesados (ffmpeg, whisper, LLM) não rodam dentro do racoon.

                         ┌───────────────────────────────────────────┐
                         │                RACOON API                  │
   Cliente / BFF ──────► │  (Fastify, Prisma, BullMQ producer)        │
   x-client-id           │  upload · listagem · pastas · features     │
                         │  transcrição · summary · SSE · webhooks    │
                         └───┬───────────┬───────────┬────────────────┘
                             │ enqueue   │ enqueue   │ enqueue
                    ┌────────▼──┐  ┌──────▼──────┐  ┌─▼──────────────┐
                    │  media-   │  │  whisper-   │  │  summary-      │
                    │  worker   │  │  worker     │  │  worker        │
                    │ (ffmpeg)  │  │ (whisper.cpp)│ │ (LLM/Ollama)   │
                    │ transcode │  │ transcrição │  │ título+descr.  │
                    │ +thumbnail│  │ +captions   │  │                │
                    └────┬──────┘  └──────┬──────┘  └──────┬─────────┘
                         │ webhook        │ webhook        │ webhook
                         └────────────────┴────────────────┘
                                          ▼
                         POST /webhooks/{media|transcription|summary}
                                          │
   ┌──────────────────────┐               │
   │  Cloudflare R2        │◄──────────────┘  (workers sobem artefatos direto)
   │  bucket + domínio     │
   │  público (R2_PUBLIC_URL)
   └──────────────────────┘

Componentes

Peça Função
racoon (este repo) API HTTP, banco, filas, webhooks, serialização de mídia
media-worker (externo) Transcodifica vídeo/áudio via ffmpeg, extrai frame de thumbnail, gera variantes MP4, extrai metadados (ffprobe)
whisper-worker (externo) Transcrição de áudio (whisper.cpp), gera JSON + legendas WebVTT
summary-worker (externo) Gera título e/ou descrição a partir do texto transcrito (LLM)
PostgreSQL Estado: Upload, Transcription, Summary, VideoOutput, Client, Folder, entitlements
Redis (BullMQ) Filas de jobs entre racoon e workers; pub/sub para SSE
Cloudflare R2 Storage de originais e artefatos, servido publicamente via domínio custom

Fluxo de armazenamento no R2

uploads/<clientId>/<uuid>/original.<ext>          # arquivo original
processed/<clientId>/<uploadId>/720p.mp4          # variantes de vídeo
processed/<clientId>/<uploadId>/thumbnail.webp    # thumbnail (WebP 200px)
processed/<clientId>/<uploadId>/thumb.webp        # variante de imagem 200px
processed/<clientId>/<uploadId>/transcription.json
processed/<clientId>/<uploadId>/captions.vtt

2. Autenticação

Existem três posturas de autenticação, dependendo da rota:

2.1 x-client-id (tenant)

A maioria das rotas exige o header x-client-id. O valor é o id do Client provisionado via /admin/clients. Não há segredo/senha: o racoon confia em quem detém o x-client-id — a autorização de quem pode usar o header é responsabilidade do backend do ChatSkills (modelo BFF).

curl "$BASE_URL/files" -H "x-client-id: $CLIENT_ID"

Erros: 401 header ausente/vazio ou cliente inexistente.

2.2 x-admin-key (administrativo)

Rotas sob /admin/* exigem x-admin-key. O valor é um token opaco de alta entropia armazenado na tabela Key. Sem nenhuma linha na tabela, nenhuma requisição é autorizada. Revogação é a remoção da linha (DELETE /admin/keys/:value).

curl "$BASE_URL/admin/keys" -H "x-admin-key: $ADMIN_KEY"

2.3 Rotas públicas (sem header)

Algumas rotas são acessíveis sem qualquer header:


3. Modelo de dados

3.1 Upload

Registro central de cada arquivo. Campos relevantes:

Campo Descrição
id UUID (chave primária, usada em GET /file/:id e /files/:id)
publicId ID curto e rotacionável usado no player (GET /media/:publicId)
clientId Tenant dono
fileCategory video | audio | image | document
status Ver status de upload
scope content | avatar | user | system
r2Key Chave do original no R2
mp4Key MP4 principal (maior resolução) para o player
thumbnailKey JPEG legado (nulo em uploads novos)
thumbnailWebpKey Thumbnail WebP 200px (padrão atual)
blurhash Placeholder blurhash (4×3) derivado do thumbnail
dominantColor Cor dominante #rrggbb do thumbnail
width / height / duration / format Metadados de mídia
pageCount Nº de páginas (PDF)
bytes Tamanho do original
totalBytes Pegada total no R2 = original + todos os artefatos
folderId Pasta virtual (metadados; não afeta o R2)
deletedAt null = ativo; timestamp = na lixeira (soft-delete)

Status de upload

PENDING_UPLOAD → presign criado, PUT ainda não concluído
UPLOADING      → bytes em streaming (upload síncrono)
PROCESSING     → worker transcodificando / extraindo metadados / thumbnail
COMPLETED      → pronto para consumo
FAILED         → erro no processamento
CANCELLED      → cancelado pelo cliente

3.2 Escopos (scope)

Segrega uploads por propósito. O picker/listagem por padrão só mostra content.

Scope Uso
content Mídia do usuário (padrão do picker)
avatar Foto de perfil do membro (1 por membro)
user Outros uploads do membro (capa, anexos)
system Assets internos do tenant (logos, banners)

3.3 Tiers de cliente (ClientTier)

3.4 Features (ClientFeature)

Gating por cliente/feature (ver seção 11): TRANSCODE, TRANSCRIPTION, SUMMARY, DUBBING (futuro).


4. Convenções e ambiente

Todos os exemplos assumem:

export BASE_URL="https://racoon.prod.chatskills.io"
export CLIENT_ID="your-client-id"      # provisionado pelo backend do ChatSkills
export ADMIN_KEY="your-admin-token"    # apenas para rotas /admin/*

Formato de erro (todas as rotas):

{ "statusCode": 404, "error": "Not Found", "message": "Upload not found" }

Códigos de status usados

Código Significado
400 Validação (campo inválido/ausente)
401 Falta x-client-id / x-admin-key, ou credencial inválida
403 Feature/quota negada, ou origem não permitida em /media
404 Recurso não encontrado
409 Conflito de estado (ex.: cancelar upload já concluído)
413 Arquivo excede o limite ou a quota
415 MIME não suportado
422 Objeto não encontrado no R2 / divergência de tamanho no /complete
429 Rate limit (SSE)
500 Erro interno

Variáveis de ambiente principais (ver src/config/env.ts)

Var Padrão Descrição
R2_PUBLIC_URL Domínio público do bucket (base de todas as URLs de mídia)
RACOON_BASE_URL URL pública do racoon (usada nos webhookUrl enviados aos workers)
MAX_FILE_SIZE_VIDEO 5368709120 (5 GB) Limite global de vídeo
MAX_FILE_SIZE_AUDIO 524288000 (500 MB) Limite global de áudio
MAX_FILE_SIZE_IMAGE 52428800 (50 MB) Limite global de imagem
MAX_FILE_SIZE_DOCUMENT 104857600 (100 MB) Limite global de documento
SHORT_VIDEO_MAX_DURATION_SEC 60 Vídeos ≤ isto também ganham MP4 de fallback
DELETE_RETENTION_MINUTES 10080 (7 dias) Janela da lixeira antes do purge
WHISPER_WEBHOOK_SECRET / SUMMARY_WEBHOOK_SECRET opcional Segredo x-webhook-secret dos workers

Limites de tamanho podem ser sobrescritos por cliente via maxFileSize{Video,Audio,Image,Document} (ver seção 14).


5. Upload de arquivos

Há dois caminhos: síncrono (multipart, arquivo passa pelo racoon) e presigned (dois passos, cliente sobe direto para o R2 — recomendado para arquivos grandes).

5.1 Upload síncrono (multipart)

POST /upload[?scope=content|avatar|user|system]

O scope vai na query string (não no corpo multipart). Campos opcionais transcribe, language, timestampsMode podem acompanhar o arquivo.

curl -X POST "$BASE_URL/upload?scope=content" \
  -H "x-client-id: $CLIENT_ID" \
  -F "file=@/caminho/video.mp4;type=video/mp4"

Com transcrição no mesmo upload:

curl -X POST "$BASE_URL/upload" \
  -H "x-client-id: $CLIENT_ID" \
  -F "file=@/caminho/video.mp4;type=video/mp4" \
  -F "transcribe=true" \
  -F "language=pt" \
  -F "timestampsMode=word"

Respostas por categoria:

5.2 Upload presigned (dois passos)

Passo 1 — obter URL assinada:

curl -X POST "$BASE_URL/upload/presign" \
  -H "x-client-id: $CLIENT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "video.mp4",
    "mime": "video/mp4",
    "size": 104857600,
    "transcribe": true,
    "language": "pt",
    "scope": "content"
  }'

Resposta 201:

{
  "uploadId": "…",
  "publicId": "…",
  "putUrl": "https://…r2…/uploads/…?X-Amz-Signature=…",
  "headers": { "Content-Type": "video/mp4" },
  "expiresAt": "2026-07-13T12:15:00.000Z"
}

Passo 2 — PUT direto no R2 (o Content-Type precisa bater com o mime):

curl -X PUT "<putUrl>" \
  -H "Content-Type: video/mp4" \
  --data-binary "@/caminho/video.mp4"

Passo 3 — finalizar (racoon confere o objeto via HEAD e dispara o pipeline):

curl -X POST "$BASE_URL/upload/<uploadId>/complete" \
  -H "x-client-id: $CLIENT_ID"

5.3 Cancelar processamento

curl -X POST "$BASE_URL/upload/<uploadId>/cancel" \
  -H "x-client-id: $CLIENT_ID"

Mata o transcode em andamento (flag de cancelamento no Redis + remoção da fila). 409 se o upload não estiver em PROCESSING/UPLOADING.

5.4 Rotacionar publicId (invalidar links antigos)

curl -X POST "$BASE_URL/upload/<uploadId>/rotate-public-id" \
  -H "x-client-id: $CLIENT_ID"

6. Consulta e entrega de mídia

6.1 Detalhe completo (dashboard/manager) — auth

curl "$BASE_URL/upload/<uploadId>" -H "x-client-id: $CLIENT_ID"

Retorna o objeto de mídia completo (toMedia): url, streaming, variants, thumbnail: { url } (WebP), blurhash, dominantColor, transcriptions[], processingStatus, totalBytes, etc.

6.2 Metadados por id [PÚBLICO]

curl "$BASE_URL/files/<fileId>"     # shape rico (FileItem) — thumbnail WebP
curl "$BASE_URL/file/<fileId>"      # shape enxuto — atenção ao thumbnail (ver nota)

⚠️ Nota: GET /file/:id retorna thumbnailUrl apenas a partir do thumbnailKey (JPEG legado) — para uploads novos (que usam WebP) o campo vem null. Para obter a thumbnail atual use GET /files/:id, GET /upload/:id ou GET /media/:publicId?kind=thumbnail.

6.3 Player público

GET /media/:publicId
GET /media/:publicId?variant=720p|1080p|mp4
GET /media/:publicId?kind=captions[&lang=pt]   # 302 → WebVTT
GET /media/:publicId?kind=thumbnail            # 302 → URL da thumbnail
# de um domínio permitido, sem header:
curl "$BASE_URL/media/<publicId>"

# de fora da allowlist, com header:
curl "$BASE_URL/media/<publicId>" -H "x-client-id: $CLIENT_ID"

# seguir o redirect da thumbnail:
curl -IL "$BASE_URL/media/<publicId>?kind=thumbnail" -H "x-client-id: $CLIENT_ID"

A resposta (toPublicMedia) inclui ready, url, streaming, variants, thumbnail, blurhash, dominantColor, captions[].


7. Listagem, picker, stats e lixeira

7.1 Listagem paginada

curl "$BASE_URL/files?page=1&limit=20&category=video&sortBy=createdAt&sortOrder=desc" \
  -H "x-client-id: $CLIENT_ID"

Query: page, limit, category, status, search, sortBy, sortOrder, folderId (root | uuid | omitido), scope (contentall, padrão content).

7.2 Picker (cursor, payload enxuto)

curl "$BASE_URL/uploads/pick?limit=30&categories=video,audio&scope=content" \
  -H "x-client-id: $CLIENT_ID"
# próxima página:
curl "$BASE_URL/uploads/pick?cursor=<nextCursor>&limit=30" -H "x-client-id: $CLIENT_ID"

Retorna { items[], nextCursor, recents[] }. Cada item traz thumbnailUrl (WebP), blurhash, dominantColor — ideal para grid.

7.3 Estatísticas de armazenamento

curl "$BASE_URL/uploads/stats"  -H "x-client-id: $CLIENT_ID"   # picker-shaped
curl "$BASE_URL/files/stats"    -H "x-client-id: $CLIENT_ID"   # detalhado
curl "$BASE_URL/uploads/stats/period?period=month&categories=video,image" \
  -H "x-client-id: $CLIENT_ID"

period: today|week|month|year|all, ou since/until (ISO). totalBytes:null em /uploads/stats significa quota ilimitada.

7.4 Lixeira (soft-delete)

# mover para lixeira (padrão):
curl -X DELETE "$BASE_URL/files/<fileId>" -H "x-client-id: $CLIENT_ID"

# apagar de vez (pula/esvazia lixeira, libera quota, remove do R2):
curl -X DELETE "$BASE_URL/files/<fileId>?permanent=true" -H "x-client-id: $CLIENT_ID"

# listar lixeira:
curl "$BASE_URL/files/trash?page=1&limit=20" -H "x-client-id: $CLIENT_ID"

# restaurar:
curl -X POST "$BASE_URL/files/<fileId>/restore" -H "x-client-id: $CLIENT_ID"

# apagar TODOS (guarda de segurança):
curl -X DELETE "$BASE_URL/files?permanent=true" \
  -H "x-client-id: $CLIENT_ID" \
  -H "x-confirm-delete: DELETE_ALL"

Na lixeira a quota permanece consumida; só é liberada no delete permanente ou pelo purge do cron após DELETE_RETENTION_MINUTES.

7.5 Renomear / mover / metadados

curl -X PATCH "$BASE_URL/files/<fileId>" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "name": "novo-nome.mp4", "folderId": "<uuid-ou-null>" }'

8. Pastas (folders)

Pastas são virtuais (só no Postgres; o R2 permanece flat). Aninhadas via parentId.

# criar
curl -X POST "$BASE_URL/folders" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "name": "Campanhas", "parentId": null }'

# listar (raiz ou subpasta)
curl "$BASE_URL/folders?parentId=<id>"  -H "x-client-id: $CLIENT_ID"

# detalhe + breadcrumb
curl "$BASE_URL/folders/<folderId>"     -H "x-client-id: $CLIENT_ID"

# renomear/mover
curl -X PATCH "$BASE_URL/folders/<folderId>" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "name": "Novo nome", "parentId": null }'

# explorar (subpastas + arquivos paginados numa resposta)
curl "$BASE_URL/browse?folderId=<id>&page=1&limit=20&category=video" \
  -H "x-client-id: $CLIENT_ID"

# excluir em cascata (pasta + descendentes + arquivos dentro)
curl -X DELETE "$BASE_URL/folders/<folderId>" -H "x-client-id: $CLIENT_ID"

9. Transcrição

Requer a feature TRANSCRIPTION. Só vídeo/áudio COMPLETED.

# iniciar transcrição sob demanda
curl -X POST "$BASE_URL/transcribe/<uploadId>" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "language": "pt", "timestampsMode": "word", "generateSummary": true, "summaryLanguage": "pt" }'
# status/resultado de 1 transcrição
curl "$BASE_URL/transcription/<transcriptionId>" -H "x-client-id: $CLIENT_ID"

# listar transcrições de um upload
curl "$BASE_URL/upload/<uploadId>/transcriptions" -H "x-client-id: $CLIENT_ID"

# cancelar
curl -X POST "$BASE_URL/transcription/<transcriptionId>/cancel" -H "x-client-id: $CLIENT_ID"

O resultado completo (JSON) e as legendas (WebVTT) são servidos do R2: resultUrl e captionsUrl aparecem no TranscriptionInfo de GET /upload/:id.


10. Sumarização (LLM)

Gera título e/ou descrição a partir de uma transcrição concluída. Requer a feature SUMMARY.

# a partir de uma transcrição pronta
curl -X POST "$BASE_URL/summarize/<transcriptionId>" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "field": "both", "language": "pt" }'

field: title | description | both. language é a língua de saída (não herda da transcrição; padrão pt).

# "quero um resumo deste upload" — resolve sozinho o caminho:
curl -X POST "$BASE_URL/uploads/<uploadId>/summarize" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "field": "both" }'

Três ramos automáticos: (A) transcrição pronta → enfileira Summary; (B) transcrição em andamento → marca autoSummarize; (C) sem transcrição → cria uma com autoSummarize (gate de TRANSCRIPTION + SUMMARY).

# ler o valor "atual" aceito pelo usuário
curl "$BASE_URL/transcription/<transcriptionId>/summary" -H "x-client-id: $CLIENT_ID"

# salvar (única forma de promover title/description a "current")
curl -X PATCH "$BASE_URL/transcription/<transcriptionId>/summary" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "title": "Meu título", "description": "Minha descrição" }'

# histórico de gerações
curl "$BASE_URL/transcription/<transcriptionId>/summaries" -H "x-client-id: $CLIENT_ID"
curl "$BASE_URL/summary/<summaryId>" -H "x-client-id: $CLIENT_ID"

Geração cria linhas de histórico (Summary), mas não sobrescreve o valor "current" — isso só acontece com o PATCH explícito (intenção do usuário).


11. Features / entitlements

Gating por cliente/feature. Auth por x-client-id (o backend do ChatSkills é quem decide quem pode provisionar).

# catálogo de features conhecidas
curl "$BASE_URL/features" -H "x-client-id: $CLIENT_ID"

# entitlements do cliente atual (sempre 1 entrada por feature)
curl "$BASE_URL/me/features" -H "x-client-id: $CLIENT_ID"

# provisionar/atualizar uma feature (upsert parcial)
curl -X PUT "$BASE_URL/me/features/TRANSCRIPTION" \
  -H "x-client-id: $CLIENT_ID" -H "Content-Type: application/json" \
  -d '{ "enabled": true, "monthlyQuota": 3600, "expiresAt": "2026-12-31T23:59:59Z" }'

# desabilitar (soft — preserva monthlyUsed)
curl -X DELETE "$BASE_URL/me/features/SUMMARY" -H "x-client-id: $CLIENT_ID"

# zerar uso do mês
curl -X POST "$BASE_URL/me/features/TRANSCODE/reset-usage" -H "x-client-id: $CLIENT_ID"

Quando o gate falha (403), a resposta traz feature e reason:

{ "statusCode": 403, "error": "Forbidden",
  "message": "Feature TRANSCRIPTION monthly quota exceeded (3600/3600)",
  "feature": "TRANSCRIPTION", "reason": "quota_exceeded" }

reasonnot_provisioned | disabled | expired | quota_exceeded.

Onde cada feature é cobrada:

Feature Gate Unidade de uso registrada
TRANSCODE upload de vídeo/áudio bytes de artefatos gerados
TRANSCRIPTION POST /transcribe bytes de artefatos (proxy)
SUMMARY POST /summarize 1 por geração concluída
DUBBING — (não lançado)

O uso só é debitado após o job concluir com sucesso (jobs que falham não queimam quota).


12. Eventos em tempo real (SSE)

GET /events abre um stream Server-Sent Events com o progresso dos jobs do cliente. Máx. 10 conexões simultâneas por cliente (429 acima disso).

# todos os eventos do cliente
curl -N "$BASE_URL/events" -H "x-client-id: $CLIENT_ID"

# escopo em uploads específicos (fecha com `done` quando todos terminam)
curl -N "$BASE_URL/events?uploads=<uuid1>,<uuid2>" -H "x-client-id: $CLIENT_ID"

Eventos emitidos: ready, ping (20s), upload.status, transcription.status, summary.status, done.

event: upload.status
data: {"uploadId":"…","status":"COMPLETED","media":{…},"timestamp":"…"}

Ao conectar com ?uploads=, o racoon faz catch-up emitindo o estado atual de cada upload (evita perder status já terminais).


13. Webhooks (workers → racoon)

Chamados pelos workers ao concluir/falhar. Protegidos por x-webhook-secret opcional (WHISPER_WEBHOOK_SECRET, SUMMARY_WEBHOOK_SECRET).

POST /webhooks/media/:uploadId
POST /webhooks/transcription/:transcriptionId
POST /webhooks/summary/:summaryId

Payload do media-worker (MediaWebhookPayload) — inclui o thumbnail:

{
  "status": "success",
  "durationMs": 42000,
  "data": {
    "uploadId": "…",
    "clientId": "…",
    "fileCategory": "video",
    "outputs": [
      { "resolution": "720p", "r2Key": "processed/…/720p.mp4",
        "bandwidth": 2500000, "width": 1280, "height": 720 }
    ],
    "mp4Key": "processed/…/1080p.mp4",
    "sourceMetadata": { "format": "mov", "width": 1920, "height": 1080, "duration": 42, "bytes": 10485760 },
    "thumbnail": { "r2Key": "processed/…/thumbnail.jpg", "blurhash": "L6…", "dominantColor": "#2a2a2a" },
    "artifactBytes": 8388608
  }
}

O tratamento do thumbnail neste webhook é o coração da geração de thumbnail de vídeo.

Se o upload foi CANCELLED enquanto o worker rodava, o racoon descarta o resultado e limpa os artefatos do R2.


14. Rotas administrativas

Todas exigem x-admin-key.

14.1 Clientes

# criar tenant
curl -X POST "$BASE_URL/admin/clients" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "id": "acme", "name": "ACME Ltda", "storageLimit": 10737418240 }'
# storageLimit: null = ilimitado (precisa vir explícito). tier default TENANT.

# criar cliente de conteúdo público
curl -X POST "$BASE_URL/admin/clients" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "id": "platform", "name": "Institucional", "tier": "SYSTEM_PUBLIC" }'
# auto-provisiona TRANSCODE/TRANSCRIPTION/SUMMARY (sem quota).

# atualizar (parcial)
curl -X PATCH "$BASE_URL/admin/clients/acme" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "storageLimit": null, "maxFileSizeVideo": 2147483648 }'

# snapshot (identidade + quota + rollup + features)
curl "$BASE_URL/admin/clients/acme" -H "x-admin-key: $ADMIN_KEY"

# excluir (cascata DB + purge R2)
curl -X DELETE "$BASE_URL/admin/clients/acme" \
  -H "x-admin-key: $ADMIN_KEY" -H "x-confirm-delete: DELETE_CLIENT"

14.2 Chaves de admin

curl -X POST "$BASE_URL/admin/keys" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "value": "<novo-token-de-alta-entropia>" }'
curl "$BASE_URL/admin/keys" -H "x-admin-key: $ADMIN_KEY"
curl -X DELETE "$BASE_URL/admin/keys/<value>" -H "x-admin-key: $ADMIN_KEY"

14.3 Manutenção

# limpar presigns órfãos (PENDING_UPLOAD sem /complete)
curl -X POST "$BASE_URL/admin/cleanup-pending" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "olderThanHours": 24, "dryRun": true }'

# purgar uploads com lixeira expirada
curl -X POST "$BASE_URL/admin/purge-expired-deletes" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "dryRun": false }'

# reconciliar órfãos no R2 (objetos sem linha viva)
curl -X POST "$BASE_URL/admin/reconcile" \
  -H "x-admin-key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "clientId": "acme", "olderThanHours": 24, "dryRun": true }'

15. Conteúdo público (SYSTEM_PUBLIC)

Leitura anônima de conteúdo de clientes SYSTEM_PUBLIC. Sem x-client-id.

# catálogo paginado por cursor
curl "$BASE_URL/public/system/catalog?limit=20&category=video"
curl "$BASE_URL/public/system/catalog?cursor=<nextCursor>&clientId=platform"

# detalhe de 1 arquivo (só se o dono for SYSTEM_PUBLIC; senão 404)
curl "$BASE_URL/public/files/<fileId>"

16. ★ Geração de thumbnail de vídeo

Esta seção documenta, ponta a ponta, como o racoon produz e serve a thumbnail de um vídeo — o artefato de imagem estática que representa o vídeo em grids, players e cards.

16.1 Visão geral

A thumbnail de vídeo nasce de um frame extraído do vídeo original e termina como um WebP de 200px servido publicamente pelo R2. O trabalho é dividido entre dois componentes:

Etapa Onde roda O quê
Extração do frame media-worker (ffmpeg, externo) Escolhe um frame do vídeo e gera um thumbnail.jpg; calcula blurhash e dominantColor
Pós-processamento racoon (sharp, este repo) Converte o JPEG em WebP 200px q=70, apaga o JPEG, grava as chaves no banco e contabiliza bytes
Entrega R2 + racoon Serve thumbnail.webp via domínio público; expõe thumbnail.url nas respostas

Importante: a seleção do frame (timestamp, qualidade, filtros ffmpeg) acontece dentro do media-worker, um repositório separado. O racoon não escolhe o instante do frame — ele recebe o JPEG pronto via webhook e cuida da conversão, do armazenamento e da entrega. O contrato entre os dois é o campo data.thumbnail do webhook de media.

16.2 Pipeline completo

1. POST /upload (vídeo)  ──►  Upload.status = PROCESSING
                              enqueueMediaProcessingJob(uploadId, r2Key, …)
                                      │
2. media-worker (ffmpeg)              ▼
   - transcodifica variantes MP4
   - EXTRAI 1 frame do vídeo  ──►  thumbnail.jpg  ──► sobe p/ R2
   - calcula blurhash + dominantColor do frame
                                      │
3. POST /webhooks/media/:uploadId ◄───┘   data.thumbnail = { r2Key, blurhash, dominantColor }
                                      │
4. racoon (sharp) — no handler do webhook:
   - baixa thumbnail.jpg do R2
   - generateWebpThumbnail(): resize p/ 200px (fit inside), WebP q=70
   - sobe  processed/<cid>/<uid>/thumbnail.webp
   - APAGA o thumbnail.jpg (WebP substitui de vez)
   - grava: thumbnailWebpKey, blurhash, dominantColor
            thumbnailKey = null  (JPEG não existe mais)
   - ajusta totalBytes/storageUsed: -jpgBytes +webpBytes
                                      │
5. Upload.status = COMPLETED
   emite SSE upload.status + webhook upload.completed do tenant
                                      │
6. Entrega: thumbnail.url = R2_PUBLIC_URL/processed/<cid>/<uid>/thumbnail.webp

16.3 O que o racoon faz no webhook (detalhe)

Trecho do handler POST /webhooks/media/:uploadId (src/routes/webhooks.ts):

Comportamento de falha (resiliente):

Situação Resultado
Conversão WebP falha (sharp lança) Mantém o JPEG; thumbnailKey fica setado, thumbnailWebpKey nulo. A API ainda serve o JPEG.
generateWebpThumbnail retorna null (JPEG ilegível) JPEG preservado; log de aviso para investigar a origem.
Vídeo sem thumbnail no payload Log de aviso (media-worker returned no thumbnail for video upload) — bug do worker. O frontend cai no ícone da categoria.
Upload CANCELLED durante o processamento Todos os artefatos (incl. thumbnail) são apagados do R2.

16.4 Campos no banco e nas respostas

Após o processamento, o Upload carrega:

Campo Exemplo Uso no frontend
thumbnailWebpKey processed/acme/<uid>/thumbnail.webp Chave do WebP (padrão atual)
thumbnailKey null JPEG legado (só em rows antigas / conversão falha)
blurhash L6PZfSjE… Placeholder progressivo enquanto a imagem carrega
dominantColor #2a2a2a Cor de fundo do card antes da imagem

Nas respostas de mídia (toMedia / toPublicMedia / FileItem), tudo isso aparece consolidado — a lógica é WebP-first, JPEG como fallback:

{
  "id": "…",
  "type": "video",
  "thumbnail": { "url": "https://cdn.chatskills.io/processed/acme/<uid>/thumbnail.webp" },
  "blurhash": "L6PZfSjE…",
  "dominantColor": "#2a2a2a"
}

16.5 Como obter a thumbnail (curl)

a) Direto do objeto de mídia (recomendado — sempre traz o WebP atual):

curl "$BASE_URL/upload/<uploadId>" -H "x-client-id: $CLIENT_ID" \
  | jq -r '.thumbnail.url'

b) Player público, via redirect 302:

# -I mostra o Location; -L segue até o WebP
curl -IL "$BASE_URL/media/<publicId>?kind=thumbnail" -H "x-client-id: $CLIENT_ID"

404 { "message": "No thumbnail available" } se o vídeo não tiver thumbnail.

c) Listagem/picker (grid): o campo já vem pronto:

curl "$BASE_URL/uploads/pick?categories=video&limit=30" -H "x-client-id: $CLIENT_ID" \
  | jq -r '.items[].thumbnailUrl'

Evite GET /file/:id para thumbnail de vídeo novo: esse endpoint enxuto só olha thumbnailKey (JPEG) e retorna null para uploads WebP.

16.6 Substituir a thumbnail por uma imagem custom

Serve para trocar o frame automático por uma capa escolhida — e é também a porta de entrada de capa para áudios (que não têm thumbnail automática).

POST /files/:fileId/thumbnail      (multipart, 1 imagem)
curl -X POST "$BASE_URL/files/<fileId>/thumbnail" \
  -H "x-client-id: $CLIENT_ID" \
  -F "file=@/caminho/capa.jpg;type=image/jpeg"

Resposta:

{
  "id": "<uploadId>",
  "thumbnailUrl": "https://cdn.chatskills.io/processed/…/thumbnail-a1b2c3.webp",
  "blurhash": "L9…",
  "dominantColor": "#3b3b3b"
}

⚠️ Não há reversão para o frame automático depois disso — o artefato anterior é apagado do R2. Para voltar, re-envie o vídeo.

16.7 Backfill de thumbnails legadas (JPEG → WebP)

Rows antigas que só têm thumbnailKey (JPEG) podem ser migradas para WebP com o script idempotente:

# simulação
pnpm tsx src/scripts/backfillWebpThumbnails.ts --dry-run
# execução real (opcional: limitar / filtrar por cliente)
pnpm tsx src/scripts/backfillWebpThumbnails.ts --limit=500 --client=acme

Para cada row: baixa o JPEG → gera WebP 200px → sobe thumbnail.webp → atualiza o row (seta thumbnailWebpKey, limpa thumbnailKey) → apaga o JPEG. Pula rows que já têm WebP; seguro re-executar.

16.8 Especificação do WebP gerado

Parâmetro Valor
Formato WebP
Dimensão menor lado ≤ 200px (fit: inside, sem upscale)
Qualidade 70
Alfa preservado quando a origem tem transparência
Chave R2 processed/<clientId>/<uploadId>/thumbnail.webp (ou thumbnail-<hex>.webp em override)
Content-Type image/webp
Tamanho típico ~10–30 KB (vs 200–500 KB do JPEG original)

16.9 Troubleshooting

Sintoma Causa provável Ação
thumbnail nulo em vídeo COMPLETED media-worker não enviou data.thumbnail (ffmpeg falhou ao extrair frame) Verificar logs do webhook: media-worker returned no thumbnail for video upload; reprocessar
GET /file/:id retorna thumbnailUrl: null Endpoint enxuto lê só thumbnailKey (JPEG); upload usa WebP Usar GET /files/:id, GET /upload/:id ou ?kind=thumbnail
Thumbnail antiga aparecendo após override Cache de CDN/browser O override usa chave versionada (-<hex>), então a URL muda; confirmar que o front lê a nova thumbnailUrl
Thumbnail em JPEG num upload recente Conversão WebP falhou no webhook Rodar backfill:webp; investigar o JPEG de origem

17. Tabelas de referência

17.1 MIME types aceitos

Categoria MIME types
video video/mp4, video/webm, video/quicktime, video/x-msvideo
audio audio/mpeg, audio/wav, audio/ogg, audio/aac, audio/flac
image image/jpeg, image/png, image/gif, image/webp, image/svg+xml
document application/pdf, Word/Excel/PowerPoint (doc/docx/xls/xlsx/ppt/pptx), application/json, application/zip, text/plain, text/markdown, text/csv

17.2 Limites de tamanho (padrão global)

Categoria Padrão Env
Vídeo 5 GB MAX_FILE_SIZE_VIDEO
Áudio 500 MB MAX_FILE_SIZE_AUDIO
Imagem 50 MB MAX_FILE_SIZE_IMAGE
Documento 100 MB MAX_FILE_SIZE_DOCUMENT

Sobrescrevíveis por cliente via maxFileSize{Video,Audio,Image,Document}.

17.3 Mapa de endpoints

Método Rota Auth Descrição
POST /upload client Upload síncrono (multipart)
POST /upload/presign client Gera URL assinada para PUT direto no R2
POST /upload/:id/complete client Finaliza upload presigned
POST /upload/:id/cancel client Cancela processamento
POST /upload/:id/rotate-public-id client Rotaciona publicId
GET /upload/:id client Detalhe completo (manager)
GET /files client Listagem paginada
GET /files/:id público Metadados por id (WebP thumb)
GET /file/:id público Metadados enxutos por id
GET /media/:publicId público* Player (variant/captions/thumbnail)
PATCH /files/:id client Renomear/mover/metadados
DELETE /files/:id client Lixeira / delete permanente
GET /files/trash client Listar lixeira
POST /files/:id/restore client Restaurar da lixeira
POST /files/:id/thumbnail client Substituir thumbnail (custom)
DELETE /files client Apagar todos (guarda x-confirm-delete)
GET /uploads/pick client Picker cursor-paginado
GET /uploads/stats[/period] client Estatísticas
GET /uploads/folders client Pastas (picker-shaped)
POST/GET/PATCH/DELETE /folders[...] client CRUD de pastas
GET /browse client Explorador (pastas + arquivos)
POST /transcribe/:uploadId client Iniciar transcrição
GET /transcription/:id client Status/resultado
GET /upload/:id/transcriptions client Listar transcrições
POST /transcription/:id/cancel client Cancelar transcrição
POST /summarize/:transcriptionId client Gerar título/descrição
POST /uploads/:id/summarize client Resumo do upload (auto-encadeia)
GET/PATCH /transcription/:id/summary client Ler/salvar título+descrição current
GET /transcription/:id/summaries client Histórico de summaries
GET /summary/:id client Um summary
GET /features client Catálogo de features
GET/PUT/DELETE /me/features[/:feature] client Entitlements do cliente
POST /me/features/:feature/reset-usage client Zerar uso
GET /events client Stream SSE
POST /webhooks/media/:id worker Callback do media-worker (thumbnail!)
POST /webhooks/transcription/:id worker Callback do whisper-worker
POST /webhooks/summary/:id worker Callback do summary-worker
POST/PATCH/GET/DELETE /admin/clients[...] admin Gestão de clientes
POST/GET/DELETE /admin/keys[...] admin Gestão de chaves admin
POST /admin/{cleanup-pending,purge-expired-deletes,reconcile} admin Manutenção
GET /public/system/catalog público Catálogo SYSTEM_PUBLIC
GET /public/files/:id público Detalhe SYSTEM_PUBLIC
GET /health público Health check

* /media/:publicId dispensa header a partir de domínios da allowlist; senão exige x-client-id.


Gerado a partir do código-fonte em src/ — mantenha em sincronia ao alterar rotas.