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 (
statusapproved/rejected viaPATCH /media/highlights/<id>/), a IA só propõe/pré-seleciona — nada é publicado automaticamente. - Positivo:
PipelineJobdá um lugar único pra consultar "o que tá rodando" porMedia, sem precisar somar campos de status espalhados porVideoHighlighta cada feature nova. - Positivo: filas Celery seguem a segregação já estabelecida (ADR 0005) —
generate_video_highlights_taskno worker leve (só chamadas LLM),render_video_highlight_task/select_highlight_cover_frame_tasknoworker-heavy(FFmpeg local), nenhum worker novo precisou ser criado. - Negativo:
PipelineJobe os campos de status existentes agora coexistem como duas fontes de verdade parcialmente sobrepostas (Media.highlights_statusvs.PipelineJobcomjob_type=highlights_generate) — aceito deliberadamente (ver acima) em vez de migrarMedia/VideoHighlightinteiros praPipelineJobnuma mudança maior e mais arriscada. - Negativo:
vertical_storyfica só no enum, sem implementação — qualquer chamador que tentar usar esserender_stylerecebe 400 (HighlightRenderRequestSerializersó aceitaRAWna 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).
Related
backend/apps/media/models.py—VideoHighlight,PipelineJob; migrations0019_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 destructuring.py, reusado porhighlights.py).backend/apps/media/services/ffmpeg.py—trim_segment,concat_segments,extract_frames,select_representative_frame.backend/apps/media/tasks.py—generate_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.py—MediaGenerateHighlightsView,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
PipelineJobcomplementa. - ADR 0005 — segregação de filas que as novas tasks seguem (
defaultvsheavy).