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, vejaintegrations/feature-entitlements.md.
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
Existem três posturas de autenticação, dependendo da rota:
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.
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"
Algumas rotas são acessíveis sem qualquer header:
GET /healthGET /file/:id e GET /files/:fileId — leitura de metadados por idGET /public/* — catálogo de conteúdo SYSTEM_PUBLICGET /media/:publicId — player-facing: liberado a partir de domínios
*.chatskills.io, *.chatskills.com.br, *.lvh.me (por Origin/Referer);
de qualquer outra origem, exige x-client-id.POST /webhooks/* — chamado pelos workers (protegido por segredo compartilhado opcional)UploadRegistro 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) |
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
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) |
ClientTier)TENANT (padrão) — cliente pagante, com quota e conteúdo privado (leitura
não exige auth, mas URLs são imprevisíveis e não há listagem pública).SYSTEM_PUBLIC — conteúdo da plataforma (vídeos institucionais, demos),
sem billing/quota, exposto via /public/*.ClientFeature)Gating por cliente/feature (ver seção 11):
TRANSCODE, TRANSCRIPTION, SUMMARY, DUBBING (futuro).
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).
Há dois caminhos: síncrono (multipart, arquivo passa pelo racoon) e presigned (dois passos, cliente sobe direto para o R2 — recomendado para arquivos grandes).
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:
202 { uploadId, publicId, clientId, status: "PROCESSING", transcriptionId }
(segue para o media-worker; acompanhe por SSE ou polling)201 com o objeto de mídia completo (processada inline: metadados + variantes WebP)202 PROCESSING (media-worker gera thumbnail da 1ª página)201 COMPLETED (sem processamento)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"
202 PROCESSING para vídeo/áudio/PDF201 COMPLETED para imagem/documento comum422 se o objeto não estiver no R2 ou o tamanho divergir > 1% do declaradocurl -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.
publicId (invalidar links antigos)curl -X POST "$BASE_URL/upload/<uploadId>/rotate-public-id" \
-H "x-client-id: $CLIENT_ID"
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.
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/:idretornathumbnailUrlapenas a partir dothumbnailKey(JPEG legado) — para uploads novos (que usam WebP) o campo vemnull. Para obter a thumbnail atual useGET /files/:id,GET /upload/:idouGET /media/:publicId?kind=thumbnail.
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[].
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 (content…all, padrão content).
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.
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.
# 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.
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>" }'
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"
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" }'
language omitido → auto-detecção (whisper).timestampsMode: word | line.generateSummary: true → encadeia um Summary ao concluir (exige também SUMMARY).409 se já houver transcrição no mesmo idioma em andamento (idiomas
diferentes rodam em paralelo).# 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.
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 oPATCHexplícito (intenção do usuário).
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" }
reason ∈ not_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).
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).
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.
Todas exigem x-admin-key.
# 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"
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"
# 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 }'
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>"
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.
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.thumbnaildo webhook de media.
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
Trecho do handler POST /webhooks/media/:uploadId
(src/routes/webhooks.ts):
generateWebpThumbnail()
(src/utils/mediaMetadata.ts):sharp(buffer, { failOn: 'none' })
.resize({ width: 200, height: 200, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 70 })
→ menor lado ≤ 200px, sem upscale, preservando alfa quando existe.thumbnailWebpKey = processed/<cid>/<uid>/thumbnail.webp
e força thumbnailKey = null (o JPEG foi apagado).totalBytes e Client.storageUsed recebem o delta
artifactBytes + webpBytes − jpgBytes.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. |
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"
}
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/:idpara thumbnail de vídeo novo: esse endpoint enxuto só olhathumbnailKey(JPEG) e retornanullpara uploads WebP.
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"
image/jpeg, image/png, image/webp.processed/<cid>/<uid>/thumbnail-<hex>.webp
(cache-bust por URL), apaga o artefato anterior e re-deriva blurhash +
dominantColor da nova imagem.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.
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.
| 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) |
| 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 |
| 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 |
| 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}.
| 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.