Pular para conteúdo

Upload de vídeos de produto

Vídeos enriquecem anúncios e páginas de produto. O Gubee processa, transcodeia e distribui via CDN própria. Este documento descreve o fluxo completo de upload via surface de integração (/integration/videos/*).

Para o fluxo equivalente no front web interno, veja /bffweb/videos/* — contratos idênticos, apenas o prefixo do path muda.

Modelo

ERP/integrador         BFF                gubee-media              bucket COS
   │                    │                     │                         │
   │  POST /upload-url  │                     │                         │
   │───────────────────>│  POST /upload-url   │                         │
   │                    │────────────────────>│                         │
   │                    │  presignedPutUrl    │                         │
   │                    │      + videoId      │                         │
   │                    │<────────────────────│                         │
   │  presignedPutUrl   │                     │                         │
   │       + videoId    │                     │                         │
   │<───────────────────│                     │                         │
   │                                                                          │
   │  PUT direto no bucket (bytes — NÃO passa pelo BFF)                     │
   │─────────────────────────────────────────────────────────────────────────>│
   │  200/204                                                                │
   │<─────────────────────────────────────────────────────────────────────────│
   │                    │                     │                         │
   │  POST /{id}/commit │                     │                         │
   │───────────────────>│  HEAD + enqueue     │                         │
   │                    │────────────────────>│                         │
   │  202 (UPLOADED)    │                     │                         │
   │<───────────────────│                     │                         │
   │                                                                          │
   │       [worker transcodeia em ~30-90s]                                    │
   │                                                                          │
   │  GET /{videoId}    │                     │                         │
   │───────────────────>│────────────────────>│                         │
   │  status: READY     │                     │                         │
   │  cdnUrl + posterUrl│                     │                         │
   │<───────────────────│                     │                         │

Pontos críticos:

  • O BFF não proxia o binário do vídeo. O integrador faz PUT direto no bucket COS usando a URL pré-assinada.
  • O bucket tem CORS aberto para https://*.gubee.com.br. Para upload de fora desse domínio (ERP server-side), CORS não se aplica.
  • O videoId é retornado em /upload-url e deve ser guardado — é a chave para commit, polling, anexação ao produto e eventual delete.

Limites por VideoType

Tipo Tamanho máximo Duração máxima Use case
SHORT 100 MB (104857600 B) 90 segundos Vídeo curto de produto, "stories" de ad.
LONG 500 MB (524288000 B) 5 minutos Demo detalhada, vídeo institucional.

A validação acontece em 3 lugares:

  1. /upload-url: contra declaredSizeBytes (rejeita 400 imediatamente).
  2. PUT no bucket: COS recusa se exceder.
  3. /commit: HEAD no bucket revalida o tamanho real (rejeita 422 se divergir do declarado).

Status lifecycle

PENDING_UPLOAD ──PUT no bucket──► UPLOADED ──commit──► PROCESSING ──sucesso──► READY
                                       │                    │
                                       │                    └──falha──► FAILED
                                       └──sem commit por >1h──► EXPIRED

READY/FAILED ──DELETE──► DELETED (soft; bytes apagados em 30 dias)

Passo 1 — Solicitar URL pré-assinada

POST /integration/videos/upload-url

Request

curl -X POST 'https://api.gubee.com.br/integration/videos/upload-url' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "videoType": "SHORT",
    "externalId": "SKU-1001-BLK",
    "ownerType": "PRODUCT",
    "ownerId": "sku-uuid-1001-blk",
    "declaredMimeType": "video/mp4",
    "declaredSizeBytes": 24576000,
    "fileExtension": "mp4"
  }'

Body (VideoUploadUrlRequest)

Campo Tipo Obrigatório Notas
videoType "SHORT" / "LONG" sim Define os limites de tamanho/duração.
externalId string não Natural key do seller (geralmente SKU). Vira parte do videoId.
ownerType "PRODUCT" / "ANNOUNCEMENT" não Pode ser informado aqui ou no commit.
ownerId string não ID do produto/anúncio dono.
declaredMimeType string sim Hoje apenas video/mp4 é processado.
declaredSizeBytes long sim Validado contra o limite do videoType.
fileExtension string sim Allowlist: mp4, mov, webm, mkv, avi.

Response 200 (VideoUploadUrlResponse)

{
  "videoId": "sellerA_SKU-1001-BLK",
  "presignedPutUrl": "https://media.gubee.com.br/video/raw/sellerA/SKU-1001-BLK.mp4?X-Amz-Algorithm=...",
  "bucketKey": "video/raw/sellerA/SKU-1001-BLK.mp4",
  "expiresAt": "2026-07-07T13:30:00Z",
  "maxBytes": 104857600
}
Campo Tipo Descrição
videoId string ID interno do gubee-media. Guarde para todos os próximos passos.
presignedPutUrl string URL para PUT direto no bucket COS. Expira em expiresAt.
bucketKey string Path interno no bucket (informativo).
expiresAt ISO-8601 Expiração do presignedPutUrl (geralmente +1h).
maxBytes long Limite efetivo para este upload.

Passo 2 — Upload direto no bucket COS

Faça PUT direto na URL pré-assinada. Não envie Authorization — a autenticação é embutida na query string.

curl -X PUT \
  -H 'Content-Type: video/mp4' \
  --upload-file ./meu-video.mp4 \
  'https://media.gubee.com.br/video/raw/sellerA/SKU-1001-BLK.mp4?X-Amz-Algorithm=...'

Respostas esperadas

Status Significado
200 Upload aceito (algumas libs retornam 204).
403 URL expirou ou assinatura inválida. Gere outra.
413 Excede o tamanho declarado. Reduza o arquivo ou suba como LONG.

Não há retry explícito no bucket. Se o PUT falhar por erro de rede, refaça a operação dentro da janela de expiresAt.

Passo 3 — Confirmar upload (commit)

POST /integration/videos/{videoId}/commit

Confirma que o PUT no bucket concluiu. O servidor faz HEAD no objeto, valida o tamanho real, transiciona para UPLOADED e enfileira para o worker de transcode.

curl -X POST 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK/commit' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "ownerType": "PRODUCT",
    "ownerId": "sku-uuid-1001-blk"
  }'

Body (VideoCommitRequest) — opcional

Pode ser omitido se ownerType e ownerId foram informados no /upload-url. Útil para o padrão eager upload — quando o ownerId (SKU/produto) ainda não existia no momento do /upload-url e foi criado entre os dois passos.

Response 202 (VideoCommitResponse)

{
  "videoId": "sellerA_SKU-1001-BLK",
  "status": "UPLOADED"
}

Erros comuns

Status Tipo Causa
400 /errors/validation Payload mal-formado ou ownerType sem ownerId.
404 /errors/not-found videoId não existe ou não pertence ao seller.
409 /errors/invalid-state Status atual ≠ PENDING_UPLOAD (commit duplicado).
410 /errors/upload-expired presignedPutUrl expirou sem PUT. Gere outra.
422 /errors/video-size-exceeded HEAD revelou tamanho real > declarado.

Passo 4 — Polling de status

GET /integration/videos/{videoId} — faça polling até status terminal.

curl 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK' \
  -H 'Authorization: Bearer $TOKEN'

Response — READY

{
  "videoId": "sellerA_SKU-1001-BLK",
  "status": "READY",
  "videoType": "SHORT",
  "ownerType": "PRODUCT",
  "ownerId": "sku-uuid-1001-blk",
  "cdnUrl": "https://media.gubee.com.br/video/final/sellerA/SKU-1001-BLK.mp4",
  "posterUrl": "https://media.gubee.com.br/video/poster/sellerA/SKU-1001-BLK.jpg",
  "durationMs": 45000,
  "width": 1080,
  "height": 1920,
  "sizeBytes": 8400000
}

Response — FAILED

{
  "videoId": "sellerA_SKU-1001-BLK",
  "status": "FAILED",
  "videoType": "SHORT",
  "ownerType": "PRODUCT",
  "ownerId": "sku-uuid-1001-blk",
  "failureReason": "EXCEEDS_DURATION",
  "retryCount": 1
}

Estratégia de polling

  • Inicie após ~5s do commit.
  • Backoff exponencial: 5s, 10s, 15s, 20s, 20s, ...
  • Timeout total: 5 min (worst case do LONG).
  • Estados terminais: READY (sucesso) ou FAILED (erro irrecuperável).

VideoStatus — todos os valores

Status Significado
PENDING_UPLOAD URL emitida, esperando PUT no bucket.
UPLOADED Commit feito, na fila do worker.
PROCESSING Worker está transcodeando.
READY Pronto. cdnUrl disponível.
FAILED Erro irrecuperável. failureReason populado.
EXPIRED PENDING_UPLOAD sem commit por mais de 1h.
DELETED Soft-deleted pelo seller.

VideoFailureReason

Valor Causa
PROBE_FAILED FFprobe não conseguiu ler (arquivo corrompido).
UNSUPPORTED_CODEC Codec fora da allowlist.
EXCEEDS_DURATION Duração acima do limite do videoType.
EXCEEDS_SIZE Tamanho real maior que o declarado.
INVALID_DIMENSIONS Resolução abaixo do mínimo.
TRANSCODE_TIMEOUT FFmpeg passou do timeout (240s default).
TRANSCODE_FAILED FFmpeg crashou.
UPLOAD_TO_BUCKET_FAILED Erro subindo resultado pro COS.
INTERNAL_ERROR Outros erros ou retryCount esgotado.

Listar vídeos

curl 'https://api.gubee.com.br/integration/videos?status=READY&limit=20&skip=0' \
  -H 'Authorization: Bearer $TOKEN'

Query params

Parâmetro Tipo Default Descrição
status VideoStatus Filtra por status.
limit int 50 Tamanho da página.
skip long 0 Offset.

Retorna List<VideoStatusResponse> ordenado por data de criação descendente.

Anexar vídeo a uma variação de produto

Vídeos não são criados via payload de produto — eles já existem (você tem o videoId). A anexação controla metadados de apresentação (main e order).

Bulk via PUT /integration/products/{externalId}

Padrão round-trip: le o produto, ajusta videos[], re-envia integral.

curl -X PUT 'https://api.gubee.com.br/integration/products/prod-erp-1001' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "...todos os campos do produto...",
    "variations": [
      {
        "sku": "SKU-1001-BLK",
        "skuId": "sku-uuid-1001-blk",
        "videos": [
          { "videoId": "sellerA_SKU-1001-BLK", "main": true,  "order": 0 },
          { "videoId": "sellerA_SKU-1001-INTRO", "main": false, "order": 1 }
        ]
      }
    ]
  }'

Regras do merge cirúrgico (bulk)

Cenário Comportamento
videos ausente / vazio Lista atual preservada integralmente.
Item com videoId já existente main e order aplicados. Demais campos preservados.
Item com videoId desconhecido Descartado. Não é possível criar vídeo via PUT.
Vídeo omitido do payload Não é deletado. Use DELETE /videos/{videoId}.
Dois itens com main=true Backend não valida — ambos ficam main=true (indefinido).
Campos cdnUrl, posterUrl, status, etc. Ignorados silenciosamente (round-trip seguro).

Garanta exatamente um main=true por variação. O backend não valida essa unicidade.

Granular via PATCH

Para mudar um único vídeo sem reescrever a lista:

curl -X PATCH 'https://api.gubee.com.br/integration/products/prod-erp-1001/variations/sku-uuid-1001-blk/videos/sellerA_SKU-1001-BLK' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "main": true,
    "order": 0
  }'
  • {identifier} no path aceita videoId ou uuid legacy — o backend tenta os dois.
  • Response: { "modified": true } (sucesso) ou { "modified": false } (no-op: vídeo não existe na variação, ou body sem main/order).

Quando usar bulk vs granular

Caso Recomendado
Toggle "definir como principal" (1 vídeo) PATCH .../videos/{videoId} com {main:true}
Reordenar a lista inteira (N vídeos) PUT /products/{id} com videos[] compacto
Tela de edição completa do produto PUT /products/{id} (já vai no payload)

Soft-delete

DELETE /integration/videos/{videoId} marca como DELETED. Os bytes permanecem no bucket por 30 dias (janela de rollback) e são removidos pelo scheduler após esse período.

curl -X DELETE 'https://api.gubee.com.br/integration/videos/sellerA_SKU-1001-BLK' \
  -H 'Authorization: Bearer $TOKEN'

Retorna 204 No Content.

Pós-delete

  • Vídeo sai das listagens filtradas (?status=READY).
  • GET /integration/videos/{videoId} ainda retorna o documento com status: DELETED por 30 dias.
  • cdnUrl permanece acessível por 30 dias (caso o seller queira voltar atrás).
  • Após 30 dias: bytes apagados, cdnUrl e posterUrl viram null.
  • O produto/anúncio dono é notificado via plataforma de streaming de eventos e o vídeo sai da lista em ~5s.

Vídeos enriquecidos no GET de produto

Em leitura (GET /integration/products/{externalId}), cada item de videos[] vem com os campos do gubee-media preenchidos via evento assíncrono na plataforma de streaming:

{
  "videos": [
    {
      "main": true,
      "name": "video-principal.mp4",
      "order": 0,
      "url": "https://media.gubee.com.br/video/final/sellerA/SKU-001.mp4",
      "uuid": "sellerA_SKU-001",
      "videoId": "sellerA_SKU-001",
      "cdnUrl": "https://media.gubee.com.br/video/final/sellerA/SKU-001.mp4",
      "posterUrl": "https://media.gubee.com.br/video/poster/sellerA/SKU-001.jpg",
      "durationMs": 45000,
      "width": 1080,
      "height": 1920,
      "sizeBytes": 8400000,
      "status": "READY",
      "failureReason": null
    }
  ]
}

Campos cdnUrl, posterUrl, durationMs, etc. são populados em background quando o evento mediaservice.video.ready chega — podem não estar presentes imediatamente após o commit. Refaça o GET em ~30-60s.

Replicação produto → anúncio

Anúncios têm updateOptions controlando quais campos replicam do produto associado. Para replicar vídeos, inclua "VIDEOS" no set:

{
  "id": "ad-uuid-123",
  "updateOptions": ["TITLE", "IMAGES", "VIDEOS"]
}
  • Default: VIDEOS desligado — vídeos não replicam automaticamente.
  • Quando ligado, mudanças de vídeo no produto disparam re-attach automático no anúncio via plataforma de streaming de eventos.

Erros

Todos os erros vêm no formato Zalando Problem (application/problem+json).

Status Type URI Quando
400 /errors/validation Payload inválido, videoType inválido.
400 /errors/video-size-exceeded declaredSizeBytes > maxBytes.
400 /errors/unsupported-video-format fileExtension fora da allowlist.
403 /errors/forbidden JWT inválido ou sem sellerId.
404 /errors/not-found videoId não existe ou sellerId não bate.
409 /errors/invalid-state Commit duplicado ou reprocess em estado inválido.
410 /errors/upload-expired URL pré-assinada expirou — gere outra.
422 /errors/video-size-exceeded Commit revelou tamanho real > declarado.
5xx (vários) gubee-media indisponível.

Exemplo completo (Python)

import os
import time
import requests

API = "https://api.gubee.com.br/integration/videos"
TOKEN = os.environ["TOKEN"]

def upload_video(file_path: str, video_type: str, owner_id: str, sku: str):
    size = os.path.getsize(file_path)
    mime = "video/mp4"
    ext = file_path.split(".")[-1].lower()

    # 1. Pega URL pré-assinada
    resp = requests.post(
        f"{API}/upload-url",
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        json={
            "videoType": video_type,
            "externalId": sku,
            "ownerType": "PRODUCT",
            "ownerId": owner_id,
            "declaredMimeType": mime,
            "declaredSizeBytes": size,
            "fileExtension": ext,
        },
    )
    resp.raise_for_status()
    presigned = resp.json()
    video_id = presigned["videoId"]
    put_url = presigned["presignedPutUrl"]

    # 2. PUT direto no bucket — sem Authorization
    with open(file_path, "rb") as f:
        put = requests.put(put_url, headers={"Content-Type": mime}, data=f)
    put.raise_for_status()

    # 3. Commit
    commit = requests.post(
        f"{API}/{video_id}/commit",
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        json={"ownerType": "PRODUCT", "ownerId": owner_id},
    )
    commit.raise_for_status()
    assert commit.json()["status"] == "UPLOADED"

    # 4. Poll status
    for _ in range(60):
        time.sleep(5)
        status = requests.get(
            f"{API}/{video_id}",
            headers={"Authorization": f"Bearer {TOKEN}"},
        ).json()

        if status["status"] == "READY":
            return status
        if status["status"] == "FAILED":
            raise RuntimeError(f"video failed: {status.get('failureReason')}")

    raise TimeoutError("video processing timed out")


result = upload_video(
    file_path="./demo-produto.mp4",
    video_type="SHORT",
    owner_id="sku-uuid-1001-blk",
    sku="SKU-1001-BLK",
)
print(f"cdnUrl: {result['cdnUrl']}")
print(f"posterUrl: {result['posterUrl']}")

Próximos passos