Skip to content

Highlights de vídeo com IA (scout+curator), render e cover frame; PipelineJob como rastreador de status genérico

Adiciona um pipeline de "cuts/highlights" com IA a apps.media: a partir da transcrição de um vídeo, um par de passadas LLM (scout + curator) seleciona candidatos a clipe curto (potencial viral), que podem então ser renderizados (corte/concat via FFmpeg) e ganhar uma thumbnail sugerida por IA visão. Introduz também PipelineJob, um rastreador de status de job assíncrono genérico, complementar (não substituto) aos pares de campos de status por-feature que Media já tinha (stems_status/dubbing_status).

Contexto

Uma aplicação de referência (Laravel, fora deste monorepo) tinha essa mesma feature bem implementada: transcrição com timestamps por palavra → passada "scout" (LLM varre a transcrição inteira, propõe candidatos amplos) → passada "curator" (LLM decide o que manter, pode costurar 2 candidatos num clipe composto setup+payoff, atribui título/hook/score) → render sob demanda via FFmpeg. apps.media já tinha um conceito parecido mas mais raso: Media.chapters/Media.highlights são marcadores de timestamp gerados por uma única passada LLM (apps.media.services.structuring), usados só para navegação — nunca viram um arquivo de vídeo real.

Decisão

Seleção (duas passadas)

apps.media.services.highlights.generate_highlights(transcript_json, duration_seconds), mesmo padrão de client/endpoint de structuring.py (settings.AI_SUMMARY_BASE_URL/API_KEY/MODEL, response_format=json_object, parsing defensivo — saída de LLM é input não confiável). Duas chamadas sequenciais: _scout_candidates (transcrição inteira, rede ampla, prefere spans de 15-60s) e _curate_highlights (recebe os candidatos com trecho de transcrição + razão do scout, decide keep/drop e stitching). Timestamps finais são ajustados (_snap_to_word_boundary) para a borda real de palavra mais próxima antes de persistir — um corte nunca cai no meio de uma palavra.

Modelo VideoHighlight

Uma row por clipe candidato, FK a Media. Campos de seleção (title, hook, description, segments, score, reasoning, status candidate/approved/rejected) separados dos campos de render (render_status, render_style, rendered_file, render_error_message) — re-renderizar (ou falhar ao renderizar) nunca deve alterar a curadoria da IA, e vice-versa. render_style já inclui vertical_story no enum (formato Reels/Shorts em retrato, com banner de headline e composição em 3 camadas) mas não está implementado — só raw (corte simples ou concat de segmentos costurados). vertical_story fica como fast-follow explícito: não bloqueia o valor central da feature (clipes candidatos identificados por IA, revisáveis/aprováveis por um humano) e evita misturar um subsistema grande (geração de imagem, heurística de seleção de frame, composição FFmpeg em 3 camadas) nesta mudança.

FFmpeg

apps/media/services/ffmpeg.py ganha trim_segment (stream-copy quando não precisa reencode; reencode quando precisa normalizar antes de concat) e concat_segments (concat demuxer, requer segments já normalizados quando > 1). Mesmo estilo de todo o resto do arquivo: subprocess.run puro, sem lib de vídeo.

Cover frame (seleção de thumbnail via IA visão)

select_highlight_cover_frame_task com 3 estratégias: exact (frame no meio do span, sem heurística), thumbnail (filtro thumbnail do próprio FFmpeg, sem IA), ai (amostra N frames no span, apps.media.services.frame_picker.pick_best_frame manda todos pra uma chamada de chat completion vision-capable — settings.AI_VISION_MODEL, mesmo endpoint de AI_SUMMARY_* — e recebe de volta o índice do melhor). JSON malformado na resposta da IA cai pro frame 0 com log de warning (é uma heurística de qualidade, não deve derrubar a task); falha real de chamada (rede/API) levanta FramePickerError, que entra em autoretry_for — vale re-tentar.

PipelineJob — aditivo, não substituto

Media já tinha o padrão de "cada side-feature opcional tem seu próprio par de campos status/error" (stems_status/stems_error_message, dubbing_status/dubbing_error_message) — isolado de propósito, pra uma falha numa feature opcional nunca reverter um Media já READY pra FAILED (ver ADR 0004). Esse padrão não escala bem pra operações escopadas abaixo de Media — um Media pode ter N VideoHighlight, cada um rodando seu próprio job de render/cover-frame, e criar um novo par de campos status/error no VideoHighlight a cada nova operação (render_status/render_error_message, depois teria que ser cover_frame_status/cover_frame_error_message, etc.) cresce mal.

PipelineJob (FK media, job_type, status running/done/failed, payload JSON — carrega chaves de escopo como highlight_id quando o job não é Media-level —, result, error, completed_at) resolve isso pra operações novas sem tocar no que já existe: Media.highlights_status/highlights_error_message continuam sendo o jeito rápido de checar "geração de highlights tá rodando?" numa query só; PipelineJob é o board genérico de "o que rodou/tá rodando pra esse media" (útil pra uma UI que quer listar todo job em andamento de uma vez, não só o mais recente de uma feature).

Cada task (generate_video_highlights_task, render_video_highlight_task, select_highlight_cover_frame_task) cria um PipelineJob no início e fecha (DONE+result) no sucesso; o on_failure de cada uma (HighlightGenerationTask, RenderHighlightTask, CoverFrameTask) também marca o PipelineJob RUNNING mais recente daquele job_type(+highlight_id quando aplicável) como FAILED, além de continuar atualizando os campos de status existentes normalmente. cover_frame em VideoHighlight não ganhou seu próprio par status/error — é rastreado só via PipelineJob, demonstrando o padrão pretendido pra próximas operações por-highlight.

Consequences

  • Positivo: escolha do que virar clipe fica com um humano (status approved/rejected via PATCH /media/highlights/<id>/), a IA só propõe/pré-seleciona — nada é publicado automaticamente.
  • Positivo: PipelineJob dá um lugar único pra consultar "o que tá rodando" por Media, sem precisar somar campos de status espalhados por VideoHighlight a cada feature nova.
  • Positivo: filas Celery seguem a segregação já estabelecida (ADR 0005) — generate_video_highlights_task no worker leve (só chamadas LLM), render_video_highlight_task/select_highlight_cover_frame_task no worker-heavy (FFmpeg local), nenhum worker novo precisou ser criado.
  • Negativo: PipelineJob e os campos de status existentes agora coexistem como duas fontes de verdade parcialmente sobrepostas (Media.highlights_status vs. PipelineJob com job_type=highlights_generate) — aceito deliberadamente (ver acima) em vez de migrar Media/VideoHighlight inteiros pra PipelineJob numa mudança maior e mais arriscada.
  • Negativo: vertical_story fica só no enum, sem implementação — qualquer chamador que tentar usar esse render_style recebe 400 (HighlightRenderRequestSerializer só aceita RAW na v1).
  • Negativo: frontend novo precisa ser construído do zero pra essa feature (lista de highlights com score/status, trigger de render + seletor de estilo, preview do rendered_file) — fora do escopo desta mudança (só backend).
  • backend/apps/media/models.pyVideoHighlight, PipelineJob; migrations 0019_media_highlights_error_message_and_more.py, 0020_videohighlight_cover_frame_pipelinejob.py.
  • backend/apps/media/services/highlights.py — seleção duas-passadas (scout+curator).
  • backend/apps/media/services/frame_picker.py — seleção de cover frame via IA visão.
  • backend/apps/media/services/transcript_render.py — helper de transcrição-pra-texto-com-timestamp compartilhado (extraído de structuring.py, reusado por highlights.py).
  • backend/apps/media/services/ffmpeg.pytrim_segment, concat_segments, extract_frames, select_representative_frame.
  • backend/apps/media/tasks.pygenerate_video_highlights_task, render_video_highlight_task, select_highlight_cover_frame_task, e os helpers _start_pipeline_job/_finish_pipeline_job/_fail_latest_pipeline_job.
  • backend/apps/media/views.pyMediaGenerateHighlightsView, MediaHighlightListView, MediaHighlightDetailView, MediaHighlightRenderView, MediaHighlightCoverFrameView, MediaPipelineJobListView.
  • backend/apps/media/tests/test_highlights_service.py, test_highlight_tasks.py, test_highlight_views.py, test_frame_picker.py, test_transcript_render.py.
  • ADR 0004 — origem do padrão "side-feature isolada com seu próprio par de campos status/error" que PipelineJob complementa.
  • ADR 0005 — segregação de filas que as novas tasks seguem (default vs heavy).

Strum — Documentação.