| from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks |
| from fastapi.responses import StreamingResponse, JSONResponse |
| from pydantic import BaseModel |
| from typing import Optional, List, Dict, Any, AsyncGenerator, Tuple |
| import os |
| import json |
| import uuid |
| import asyncio |
| import re |
| import subprocess |
|
|
| from utils.helpers import extract_json_from_text, get_gemini_model, CitationStripper |
| from srt_utils import parse_srt, shift_srt_timestamps, split_srt_on_terminal_punctuation |
| from routers.media import ( |
| get_groq_srt_base, |
| generate_metadata_internal, |
| GenerateMetadataRequest, |
| translate_srt_with_gemini, |
| build_subtitle_translation_prompt, |
| ) |
| from chat_sessions import load_session, save_session |
| import core.globals as g |
|
|
| router = APIRouter(prefix="/editor") |
|
|
| |
|
|
| class EditorChatMessage(BaseModel): |
| role: str |
| content: str |
|
|
| class EditorChatRequest(BaseModel): |
| message: str |
| history: Optional[List[EditorChatMessage]] = [] |
| |
| |
| |
| project: Dict[str, Any] = {} |
| |
| |
| |
| |
| |
| |
| chat_id: str = "" |
|
|
| class SmartCutRequest(BaseModel): |
| video_url: str |
| |
| |
| |
| |
| window_start: float |
| window_end: float |
| instruction: str |
|
|
| class DetectPausesRequest(BaseModel): |
| video_url: str |
| |
| window_start: float |
| window_end: float |
|
|
| class CreateProjectRequest(BaseModel): |
| |
| |
| |
| |
| video_url: str |
| |
| |
| context: Optional[str] = None |
|
|
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| TEMPLATE_CATALOG = [ |
| { |
| "id": "default-title-bar", |
| "name": "Title Bar", |
| "editableFields": [ |
| {"id": "title", "property": "text", "label": "Title"}, |
| {"id": "video", "property": "media", "label": "Background Video"}, |
| ], |
| }, |
| { |
| "id": "default-image-title", |
| "name": "Image + Title", |
| "editableFields": [ |
| {"id": "title", "property": "text", "label": "Title"}, |
| {"id": "image", "property": "media", "label": "Photo"}, |
| {"id": "video", "property": "media", "label": "Background Video"}, |
| ], |
| }, |
| { |
| "id": "default-title-description", |
| "name": "Title + Description", |
| "editableFields": [ |
| {"id": "title", "property": "text", "label": "Title"}, |
| {"id": "description", "property": "description", "label": "Description"}, |
| {"id": "video", "property": "media", "label": "Background Video"}, |
| ], |
| }, |
| { |
| "id": "default-image-title-description", |
| "name": "Image + Title + Description", |
| "editableFields": [ |
| {"id": "title", "property": "text", "label": "Title"}, |
| {"id": "description", "property": "description", "label": "Description"}, |
| {"id": "image", "property": "media", "label": "Photo"}, |
| {"id": "video", "property": "media", "label": "Background Video"}, |
| ], |
| }, |
| ] |
|
|
| ANIMATABLE_PROPERTIES = [ |
| "x", "y", "width", "height", "rotation", |
| "radiusTL", "radiusTR", "radiusBL", "radiusBR", |
| "scale", "opacity", "volume", "anchorX", "anchorY", |
| ] |
|
|
| EASING_TYPES = [ |
| "linear", "natural", "accelerate", "slow-down", "elastic", |
| "bounce", "overshoot", "impulse", "swing", "custom", |
| ] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _NO_BUFFER_HEADERS = { |
| "X-Accel-Buffering": "no", |
| "Cache-Control": "no-cache, no-transform", |
| "Connection": "keep-alive", |
| } |
|
|
|
|
| def _cleanup_temp_files(*paths: Optional[str]) -> None: |
| """Shared cleanup for every temp file this router creates — dedups via |
| `set()` since a couple of these paths can legitimately be the same file |
| (e.g. gemini_filepath falls back to the original download when trimming |
| doesn't apply).""" |
| for p in set(paths): |
| if p and os.path.exists(p) and "static" in p: |
| try: |
| os.remove(p) |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| |
| |
| _MAX_HEADER_LOOKAHEAD_CHARS = 4000 |
|
|
|
|
| async def stream_header_then_text(agen: AsyncGenerator) -> AsyncGenerator[Tuple[str, Any], None]: |
| """ |
| Consumes an async generator of Gemini `ModelOutput` chunks (from |
| `generate_content_stream`/`ChatSession.send_message_stream`). Looks for a |
| JSON header on the first line(s) of the response — tolerating a stray |
| ```json fence around it, since `extract_json_from_text` already strips |
| those, so re-trying a GROWING candidate against later newlines self-heals |
| if the model wraps the header in a fence despite being told not to. |
| |
| Yields exactly one `("header", dict | None)` tuple first (`None` if no |
| parseable JSON line ever showed up within `_MAX_HEADER_LOOKAHEAD_CHARS` — |
| callers should treat that as "no actions/segments, show everything"), |
| then any number of `("text", chunk)` tuples for the rest of the response, |
| verbatim, as it streams in — never re-sends anything already yielded. |
| """ |
| cumulative = "" |
| sent_len = 0 |
| search_from = 0 |
| header_yielded = False |
|
|
| async for out in agen: |
| cumulative = out.text |
| if not header_yielded: |
| while True: |
| nl = cumulative.find("\n", search_from) |
| if nl == -1: |
| break |
| candidate = cumulative[:nl] |
| parsed = extract_json_from_text(candidate) |
| if parsed is not None: |
| header_yielded = True |
| yield ("header", parsed) |
| sent_len = nl + 1 |
| break |
| search_from = nl + 1 |
|
|
| if not header_yielded and len(cumulative) > _MAX_HEADER_LOOKAHEAD_CHARS: |
| header_yielded = True |
| yield ("header", None) |
| sent_len = 0 |
|
|
| if not header_yielded: |
| continue |
|
|
| if len(cumulative) > sent_len: |
| yield ("text", cumulative[sent_len:]) |
| sent_len = len(cumulative) |
|
|
|
|
| |
|
|
| def build_editor_prompt(message: str, history: List[EditorChatMessage]) -> str: |
| history_txt = "" |
| if history: |
| lines = [] |
| for turn in history[-12:]: |
| speaker = "Usuário" if turn.role == "user" else "Você (assistente)" |
| lines.append(f"{speaker}: {turn.content}") |
| history_txt = "HISTÓRICO RECENTE DA CONVERSA:\n" + "\n".join(lines) + "\n\n" |
|
|
| templates_txt = json.dumps(TEMPLATE_CATALOG, ensure_ascii=False, indent=2) |
|
|
| return f""" |
| Você é o assistente de IA embutido num editor de vídeo web (estilo CapCut/Premiere, |
| canvas 2D, timeline com faixas). Você conversa com o usuário E pode editar o |
| projeto dele de verdade, escolhendo AÇÕES de um catálogo fechado — você NUNCA |
| escreve código nem inventa um formato novo de ação. |
| |
| O estado atual completo do projeto (elementos da timeline, canvas, seleção |
| atual) está anexado a esta mensagem como um arquivo JSON. Leia esse arquivo |
| com atenção antes de decidir qualquer coisa — ele é a verdade sobre o que já |
| existe (ids reais, posições, durações, o que está selecionado). Nunca invente |
| um `elementId` que não esteja nesse arquivo. |
| |
| {history_txt}MENSAGEM ATUAL DO USUÁRIO: |
| "{message}" |
| |
| CATÁLOGO DE AÇÕES DISPONÍVEIS (campos entre colchetes são opcionais): |
| |
| 1. add_text — {{ "type": "add_text", "text": string, ["startTime"]: number, ["duration"]: number, ["x"]: number, ["y"]: number, ["fontSize"]: number, ["fontWeight"]: "400"|"500"|"600"|"700"|"900" (peso da fonte — "400" Regular, "500" Medium, "600" SemiBold/default, "700" Bold/negrito, "900" Black; use "700" sempre que o usuário pedir texto em negrito/bold), ["fill"]: string (cor hex), ["textAlign"]: "left"|"center"|"right", ["refId"]: string }} |
| 2. add_shape — {{ "type": "add_shape", "shapeType": "rectangle"|"circle"|"triangle"|"star"|"pentagon"|"hexagon"|"diamond"|"heart"|"plus", ["startTime"]: number, ["duration"]: number, ["x"]: number, ["y"]: number, ["width"]: number, ["height"]: number, ["fill"]: string, ["refId"]: string }} |
| 3. add_title_bar — {{ "type": "add_title_bar", "text": string, ["description"]: string, ["startTime"]: number, ["duration"]: number, ["refId"]: string }} |
| 4. update_element — {{ "type": "update_element", "elementId": string, "updates": {{ ...campos parciais do elemento, ex: fill, x, y, width, height, opacity, rotation, text, fontSize... }} }} |
| 5. delete_element — {{ "type": "delete_element", "elementId": string }} |
| 6. add_keyframes — {{ "type": "add_keyframes", "elementId": string, "property": {json.dumps(ANIMATABLE_PROPERTIES)}, "keyframes": [ {{ "time": number (segundos relativos ao startTime do elemento), "value": number, ["easing"]: {json.dumps(EASING_TYPES)} }} ] }} |
| 7. insert_template — {{ "type": "insert_template", "templateId": string, ["startTime"]: number, ["overrides"]: {{ "<localId>": {{ ...campos... }} }} }} |
| 8. group_elements — {{ "type": "group_elements", "elementIds": string[], ["refId"]: string }} |
| Cria um grupo (composição) a partir de 2+ elementos existentes. O grupo |
| resultante é um elemento de verdade, com seu PRÓPRIO transform |
| (x/y/width/height/rotation/scale/opacity) — ANIMÁVEL POR KEYFRAME como |
| qualquer outro elemento (ver ação 6, add_keyframes). Se o pedido for pra |
| animar o CONJUNTO agrupado como uma unidade só (ex: "agrupe os dois e |
| anime o grupo entrando de cima pra baixo com fade") — a resposta certa é |
| SEMPRE agrupar primeiro e então aplicar `add_keyframes` no GRUPO (via |
| "refId", ver abaixo), NUNCA replicar a mesma animação em cada filho |
| individualmente: além de ser um retrabalho, animar cada filho por conta |
| própria facilmente desalinha o conjunto (cada filho tem sua própria |
| caixa/pivô, então a "mesma" animação aplicada em cada um não move o grupo |
| inteiro de forma coerente/centralizada — o jeito certo de mover/desvanecer |
| um conjunto agrupado inteiro é sempre no elemento do GRUPO, nunca nos |
| filhos). |
| 9. select_element — {{ "type": "select_element", "elementId": string }} |
| 10. smart_cut_video — {{ "type": "smart_cut_video", "elementId": string, "instruction": string }} |
| Use SOMENTE quando o pedido for pra CORTAR/EDITAR um elemento de vídeo |
| (type "image" com mediaType "video" no arquivo anexado) com base na FALA |
| dele — ex: "corta esse vídeo e deixa só as melhores falas", "tira as |
| partes de enrolação da entrevista", "deixa só os melhores momentos". |
| "elementId" tem que ser um elemento de vídeo real do arquivo anexado |
| (nunca um `refId` de um elemento que você acabou de criar). "instruction" |
| é o pedido do usuário sobre COMO cortar, repassado da forma mais fiel |
| possível. Essa ação é DIFERENTE da maioria das outras: não é aplicada na |
| hora — o editor ainda vai transcrever a fala e analisar o que manter, |
| o que pode levar cerca de um minuto. Por isso, quando usar essa ação, o |
| texto da sua resposta deve deixar isso claro (ex: "Vou ouvir o vídeo e |
| separar as melhores falas — isso pode levar um minuto."), nunca afirmar |
| que o corte já foi feito. |
| 11. analyze_media — {{ "type": "analyze_media", "elementId": string, "instruction": string }} |
| Use quando o pedido for pra SABER/DESCREVER algo sobre um elemento de |
| vídeo (type "image" com mediaType "video") ou de ÁUDIO (type "audio") do |
| arquivo anexado — algo que só dá pra responder de verdade assistindo/ |
| ouvindo o clipe: descrever a cena, quem aparece, o que está vestindo, o |
| ambiente, o tom/clima, a qualidade de imagem/áudio, música de fundo, |
| efeitos sonoros, texto que aparece na tela, resumir o que foi dito e |
| como, etc. Ex: "descreve esse vídeo pra mim", "o que essa pessoa está |
| falando e qual o tom dela", "esse áudio tem música de fundo?". Essa ação |
| é SÓ INFORMATIVA — nunca edita o documento. "elementId" tem que ser um |
| elemento de vídeo ou áudio real do arquivo anexado com `hasMedia: true` |
| (nunca um `refId` de algo que você acabou de criar). "instruction" é a |
| pergunta/pedido do usuário sobre esse clipe, repassado da forma mais |
| fiel possível. Igual smart_cut_video, essa ação NÃO é aplicada na hora — |
| o editor ainda vai assistir/ouvir o arquivo de verdade, o que pode levar |
| cerca de um minuto. O texto da sua resposta deve deixar isso claro (ex: |
| "Vou assistir/ouvir com atenção e te conto os detalhes — pode levar um |
| tempinho."), nunca afirmar que a análise já foi feita nem inventar o que |
| tem no vídeo/áudio antes de ela realmente rodar. |
| 12. remove_silence_video — {{ "type": "remove_silence_video", "elementId": string, ["minSilenceSeconds"]: number }} |
| Use quando o pedido for pra REMOVER SILÊNCIO/PAUSAS de um elemento de |
| vídeo (type "image" com mediaType "video" no arquivo anexado) — ex: |
| "remove o silêncio desse vídeo", "corta as pausas", "tira os momentos |
| sem fala", "deixa o vídeo mais dinâmico cortando o silêncio". Diferente |
| de smart_cut_video: essa ação NÃO julga o CONTEÚDO da fala (não escolhe |
| "as melhores partes", não tem `instruction`) — ela transcreve o clipe e |
| corta qualquer trecho SEM PALAVRA RECONHECIDA. Isso é deliberadamente |
| diferente de só medir o volume do áudio: uma pausa de um palestrante pra |
| receber aplausos/risadas da plateia não é SILENCIOSA (a plateia faz |
| barulho), mas também não tem fala reconhecida — então essa ação corta |
| isso corretamente, o que um limiar de volume sozinho não conseguiria. Só |
| use "minSilenceSeconds" quando o usuário der um número explícito (ex: |
| "remove pausas de mais de 1 segundo" → `"minSilenceSeconds": 1`); sem |
| isso, omita o campo e deixe o editor usar o padrão dele (~0.6s). |
| "elementId" tem que ser um elemento de vídeo real do arquivo anexado |
| (nunca um `refId` de um elemento que você acabou de criar). Essa ação |
| NÃO é aplicada na hora — o editor ainda precisa transcrever o áudio do |
| clipe primeiro (mais rápido que smart_cut_video, que ainda por cima |
| precisa de uma segunda passada do Gemini pra decidir o que manter — aqui |
| não há essa segunda passada). Por isso, quando usar essa ação, o texto da |
| sua resposta deve deixar isso claro (ex: "Vou ouvir o áudio e cortar as |
| pausas."), nunca afirmar que o corte já foi feito. |
| |
| No máximo UMA ação assíncrona (smart_cut_video, analyze_media OU |
| remove_silence_video — nunca duas ou mais na mesma resposta) por resposta — |
| são as únicas ações que não são aplicadas na hora. |
| |
| TEMPLATES DISPONÍVEIS (use o "id" exato em insert_template.templateId; os ids |
| dentro de "editableFields" podem ser usados como chave em "overrides"): |
| {templates_txt} |
| |
| REGRAS IMPORTANTES: |
| - `startTime`/tempos são sempre em SEGUNDOS, absolutos no projeto (não |
| relativos ao playhead), exceto o `time` dentro de `add_keyframes.keyframes`, |
| que é relativo ao `startTime` do PRÓPRIO elemento. |
| - COORDENADAS (CRÍTICO — erro comum, preste atenção): `x`/`y` são sempre o |
| canto SUPERIOR ESQUERDO da caixa do elemento em pixels do canvas |
| (`canvasWidth`/`canvasHeight`, vêm no JSON anexado) — NUNCA o centro do |
| elemento nem o centro da cena. Para CENTRALIZAR um elemento NOVO |
| (add_text/add_shape/add_title_bar), a forma correta é OMITIR `x` e `y` |
| completamente — o editor já centraliza automaticamente usando o tamanho |
| real que o elemento vai ter (para `add_text` isso só é conhecido depois de |
| medir o texto de verdade, então você não tem como calcular esse centro |
| sozinho — não tente "chutar" `x`/`y` como se fossem `canvasWidth/2`, |
| `canvasHeight/2`, isso posiciona o CANTO ali, não o centro, e desloca o |
| elemento pra baixo/direita do centro real). Só informe `x`/`y` quando o |
| pedido pedir uma posição específica que não seja "centralizado" (ex: "no |
| canto superior direito", "encostado na borda esquerda"). Para mover um |
| elemento que JÁ EXISTE (`update_element`), você TEM o `width`/`height` dele |
| no JSON anexado — aí sim pode calcular um centro de verdade: |
| `x = (canvasWidth - width) / 2`, `y = (canvasHeight - height) / 2`. |
| - Se você precisa CRIAR um elemento (ou um GRUPO, via group_elements) E EM |
| SEGUIDA editá-lo/animá-lo na MESMA resposta (ex: "adicione um texto e anime |
| a opacidade dele", ou "agrupe esses dois elementos e anime o grupo |
| entrando de cima pra baixo"), você ainda não sabe o id real desse |
| elemento/grupo (ele só existe depois que o editor aplicar a ação). Nesse |
| caso, invente um `refId` curto (ex: "novo_titulo", "grupo_intro") no campo |
| "refId" da ação add_text/add_shape/add_title_bar/group_elements, e use |
| ESSE MESMO valor como "elementId" na ação seguinte (add_keyframes, |
| update_element, etc) — o editor resolve o id real automaticamente. Para |
| editar um elemento OU GRUPO que já existia antes desta mensagem, use o |
| "id" real dele, do arquivo anexado (grupos aparecem no snapshot como |
| qualquer outro elemento, com "type": "group"). |
| - Cores são sempre string hex (ex: "#ff3366"). |
| - HONESTIDADE (CRÍTICO, NÃO NEGOCIÁVEL): você só pode fazer exatamente o que |
| está no catálogo de ações acima — nada além disso é possível hoje (ex: |
| gerar ou editar imagens, mexer em áudio além do que já existe como campo |
| de elemento, alterar o código/comportamento do próprio editor, acessar |
| informação de fora do que foi anexado). Cortar vídeo com base na fala JÁ é |
| possível — ver ação 10 (smart_cut_video) — não trate isso como limitação. |
| Descrever/analisar o conteúdo real de um vídeo ou áudio (o que aparece, o |
| que é dito, o clima, a qualidade) TAMBÉM já é possível — ver ação 11 |
| (analyze_media) — não trate isso como limitação. Remover silêncio/pausas de |
| um vídeo TAMBÉM já é possível — ver ação 12 (remove_silence_video) — não |
| trate isso como limitação. Animar um GRUPO inteiro como uma unidade só |
| (ex: "agrupe e anime a entrada do grupo") TAMBÉM já é possível — grupo é |
| um elemento com seu próprio transform, então add_keyframes funciona nele |
| igual em qualquer outro elemento (ver ação 8, group_elements, e seu uso de |
| "refId") — não trate isso como limitação nem diga que a animação só pode |
| ir nos elementos individuais. |
| Se o usuário pedir algo fora do catálogo, ou qualquer coisa |
| que você não tenha certeza de conseguir fazer com o catálogo, diga isso de |
| forma CLARA, HONESTA e EDUCADA no texto da resposta — algo como "ainda não |
| fui programado(a) pra fazer isso" — em vez de inventar uma solução, fingir |
| que fez, ou gerar uma ação que não corresponde de verdade ao pedido. NUNCA |
| descreva no texto da resposta uma ação que não está de fato no JSON de |
| ações desta mesma mensagem — os dois têm que bater exatamente com o que |
| você realmente fez. Na dúvida entre arriscar uma ação errada ou admitir a |
| limitação, SEMPRE admita a limitação. |
| - Se não houver nenhuma ação a fazer (o usuário só quer conversar/perguntar |
| algo, ou você não consegue atender o pedido — ver regra de HONESTIDADE |
| acima), mande "actions": [] mesmo assim. |
| - Prefira o MÍNIMO de ações necessárias pra atender o pedido — não adicione |
| elementos ou keyframes que o usuário não pediu. |
| - Responda SEMPRE no mesmo idioma que o usuário está usando na mensagem atual. |
| |
| FORMATO DE RESPOSTA (CRÍTICO — mudou, preste muita atenção): |
| Sua resposta inteira tem EXATAMENTE duas partes, nesta ordem, e nada mais: |
| |
| 1. Uma ÚNICA linha, logo no início, com um JSON compacto (sem indentação, |
| sem quebra de linha dentro dele) só com as ações: |
| {{"actions": [ ... zero ou mais ações do catálogo acima, ou [] se nenhuma ... ]}} |
| 2. Uma quebra de linha, e DEPOIS o texto da sua resposta pro usuário, DIRETO |
| — nunca dentro de aspas de JSON, nunca escapado. Pode (e deve, quando |
| fizer sentido) usar markdown de verdade: **negrito**, listas com "-", |
| cabeçalhos com "##" — isso é renderizado de verdade no chat. |
| |
| NÃO envolva NADA disso em ```json ou ``` — nem a linha de ações, nem o texto |
| depois. A primeira linha é JSON cru; o resto é texto cru. Não repita as |
| ações dentro do texto da resposta. |
| |
| Exemplo de resposta válida (ilustrativo — o conteúdo é só exemplo, siga o formato): |
| {{"actions": [{{"type": "select_element", "elementId": "abc123"}}]}} |
| Selecionei o elemento pra você. Ele já está **visível** no canvas. |
| """ |
|
|
|
|
| |
|
|
| @router.post("/chat") |
| async def editor_chat(request: EditorChatRequest): |
| if not hasattr(g, "client") or not g.client: |
| raise HTTPException(status_code=500, detail="Gemini client is not initialized") |
|
|
| stored_metadata = load_session(request.chat_id) if request.chat_id else None |
| |
| |
| |
| |
| |
| history_for_prompt = [] if stored_metadata else (request.history or []) |
| prompt = build_editor_prompt(request.message, history_for_prompt) |
|
|
| temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "temp") |
| os.makedirs(temp_dir, exist_ok=True) |
| context_path = os.path.join(temp_dir, f"ai_context_{uuid.uuid4().hex[:8]}.json") |
| with open(context_path, "w", encoding="utf-8") as f: |
| json.dump(request.project, f, ensure_ascii=False) |
|
|
| print(f"🤖 [EDITOR/CHAT] chat_id={request.chat_id!r} | sessão {'reaproveitada' if stored_metadata else 'nova'} | Mensagem: {request.message!r} | histórico reenviado: {len(history_for_prompt)} turno(s) | elementos no snapshot: {len(request.project.get('elements', []))}") |
|
|
| chat = g.client.start_chat(metadata=stored_metadata) if stored_metadata else g.client.start_chat() |
|
|
| async def body_stream(): |
| header_seen = False |
| citations = CitationStripper() |
| try: |
| async for kind, payload in stream_header_then_text(chat.send_message_stream(prompt, files=[context_path])): |
| if kind == "header": |
| header_seen = True |
| actions = payload.get("actions") if isinstance(payload, dict) else None |
| actions = actions if isinstance(actions, list) else [] |
| action_types = [a.get("type") for a in actions if isinstance(a, dict)] |
| print(f"🤖 [EDITOR/CHAT] {len(actions)} ação(ões): {action_types}") |
| yield json.dumps({"actions": actions}, ensure_ascii=False) + "\n" |
| else: |
| cleaned = citations.feed(payload) |
| if cleaned: |
| yield cleaned |
| tail = citations.flush() |
| if tail: |
| yield tail |
| except Exception as e: |
| print(f"❌ Erro no streaming de /editor/chat: {e}") |
| if not header_seen: |
| yield '{"actions": []}\n' |
| yield "Desculpe, tive um problema técnico ao gerar a resposta. Tente de novo." |
| finally: |
| if request.chat_id and chat.metadata: |
| save_session(request.chat_id, chat.metadata) |
| _cleanup_temp_files(context_path) |
|
|
| return StreamingResponse(body_stream(), media_type="text/plain; charset=utf-8", headers=_NO_BUFFER_HEADERS) |
|
|
|
|
| |
| |
| |
| |
|
|
| def build_smart_cut_prompt(instruction: str) -> str: |
| return f""" |
| Você é um editor de vídeo profissional. Você recebe a TRANSCRIÇÃO de um |
| trecho de vídeo (anexada como JSON: uma lista de blocos, cada um com "start" |
| e "end" em SEGUNDOS — tempo absoluto do arquivo fonte — e "text", a fala |
| naquele intervalo) e o PEDIDO do usuário sobre como cortar esse vídeo. |
| |
| SUA TAREFA: escolher quais blocos (um ou vários, não precisam ser |
| consecutivos) devem ser MANTIDOS no corte final, de acordo com o pedido. |
| Tudo que não for escolhido é descartado. |
| |
| REGRAS (CRÍTICAS): |
| - Você só pode usar OS TIMESTAMPS REAIS da transcrição anexada. NUNCA invente |
| ou arredonde um valor — cada trecho escolhido tem que começar e terminar |
| EXATAMENTE no "start"/"end" de um ou mais blocos consecutivos do arquivo. |
| - Os trechos escolhidos devem estar em ORDEM CRONOLÓGICA e NUNCA se |
| sobrepor. |
| - Prefira POUCOS trechos bem escolhidos a muitos trechos curtos picados. |
| - Cada trecho deve fazer sentido sozinho, como uma afirmação/ideia completa |
| — nunca corte no meio de uma frase. |
| - Se o pedido for vago (ex: "as melhores partes", "os melhores momentos"), |
| use julgamento editorial: priorize afirmações fortes, conclusivas, com |
| informação ou emoção real — evite perguntas do entrevistador, hesitação, |
| saudação/introdução genérica, repetição. |
| - Se a transcrição não tiver nada que se destaque claramente pro pedido, é |
| válido escolher poucos trechos, ou até nenhum — nunca force uma |
| quantidade só pra preencher. |
| |
| PEDIDO DO USUÁRIO: |
| "{instruction}" |
| |
| FORMATO DE RESPOSTA (CRÍTICO): sua resposta tem EXATAMENTE duas partes: |
| |
| 1. Uma ÚNICA linha, logo no início, com um JSON compacto: |
| {{"segments": [ {{ "start": number, "end": number, "text": "o texto falado nesse trecho, como veio da transcrição", "reason": "por que esse trecho foi escolhido, uma frase curta" }} ]}} |
| 2. Uma quebra de linha, e DEPOIS um resumo curto (1-2 frases), em texto |
| normal DIRETO (nunca dentro de JSON), do que foi mantido/cortado — isso |
| aparece pro usuário no chat. |
| |
| NÃO envolva NADA em ```json ou ```. Exemplo de formato (ilustrativo): |
| {{"segments": [{{"start": 12.4, "end": 18.9, "text": "...", "reason": "afirmação forte"}}]}} |
| Mantive os dois trechos mais fortes da entrevista, cortando o resto. |
| """ |
|
|
|
|
| def snap_segments_to_transcript( |
| segments: list, |
| transcript_blocks: list, |
| tolerance: float = 0.35, |
| max_segments: int = 40, |
| ) -> list: |
| """Defesa contra timestamp alucinado: cada segmento que o Gemini devolveu |
| só é aceito se o "start"/"end" dele estiver bem perto (`tolerance` |
| segundos) do start/end de um bloco REAL da transcrição — nesse caso o |
| valor é substituído pelo valor EXATO do bloco real, nunca o número cru |
| que o modelo escreveu. Fora da tolerância, o segmento inteiro é |
| descartado. Diferente de um erro de texto (Fase 1), um timestamp errado |
| aqui corrompe o vídeo cortado, então essa validação não é opcional. |
| Depois: ordena por tempo, descarta sobreposição, e limita a |
| `max_segments` (segurança contra uma resposta anormalmente picada). |
| """ |
| if not transcript_blocks: |
| return [] |
|
|
| valid = [] |
| for seg in segments: |
| if not isinstance(seg, dict): |
| continue |
| start, end = seg.get("start"), seg.get("end") |
| if not isinstance(start, (int, float)) or not isinstance(end, (int, float)) or end <= start: |
| continue |
|
|
| nearest_start_block = min(transcript_blocks, key=lambda b: abs(b["start"] - start)) |
| nearest_end_block = min(transcript_blocks, key=lambda b: abs(b["end"] - end)) |
| if abs(nearest_start_block["start"] - start) > tolerance or abs(nearest_end_block["end"] - end) > tolerance: |
| continue |
|
|
| valid.append({ |
| "start": nearest_start_block["start"], |
| "end": nearest_end_block["end"], |
| "text": seg.get("text") or "", |
| "reason": seg.get("reason") or "", |
| }) |
|
|
| valid.sort(key=lambda s: s["start"]) |
| result = [] |
| last_end = -1.0 |
| for s in valid: |
| if s["start"] < last_end: |
| continue |
| result.append(s) |
| last_end = s["end"] |
| if len(result) >= max_segments: |
| break |
| return result |
|
|
|
|
| @router.post("/smart-cut") |
| async def editor_smart_cut(request: SmartCutRequest): |
| if not hasattr(g, "client") or not g.client: |
| raise HTTPException(status_code=500, detail="Gemini client is not initialized") |
| if request.window_end <= request.window_start: |
| raise HTTPException(status_code=400, detail="Invalid time window.") |
|
|
| original_media_path = None |
| context_path = None |
| try: |
| print(f"🎬 [SMART-CUT] video_url={request.video_url} | janela=[{request.window_start:.2f}, {request.window_end:.2f}] | instrução={request.instruction!r}") |
|
|
| srt_base, _, processed_audio_url, _word_level, original_media_path = await get_groq_srt_base( |
| request.video_url, time_start=request.window_start, time_end=request.window_end, |
| ) |
|
|
| |
| |
| |
| |
| |
| audio_log_url = processed_audio_url.replace("/static/", "/static/processed/", 1) if processed_audio_url else None |
| print(f"🔊 [SMART-CUT] Áudio processado (o que o Whisper de fato ouviu): {audio_log_url}") |
| print(f"🎙️ [SMART-CUT] Transcrição bruta (relativa à janela recortada):\n{srt_base}") |
|
|
| |
| |
| |
| |
| srt_absolute = shift_srt_timestamps(srt_base, request.window_start) |
| transcript_blocks = parse_srt(srt_absolute) |
| if not transcript_blocks: |
| print("⚠️ [SMART-CUT] Nenhum bloco de fala reconhecido nessa janela — abortando sem chamar o Gemini.") |
| _cleanup_temp_files(original_media_path) |
|
|
| async def empty_body(): |
| yield '{"segments": []}\n' + "I couldn't find any speech in this clip to analyze." |
|
|
| return StreamingResponse(empty_body(), media_type="text/plain; charset=utf-8", headers=_NO_BUFFER_HEADERS) |
|
|
| print(f"🕒 [SMART-CUT] Transcrição em tempo absoluto ({len(transcript_blocks)} blocos):") |
| for b in transcript_blocks: |
| print(f" [{b['start']:.2f} - {b['end']:.2f}] {b['text']}") |
|
|
| temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "temp") |
| os.makedirs(temp_dir, exist_ok=True) |
| context_path = os.path.join(temp_dir, f"smart_cut_transcript_{uuid.uuid4().hex[:8]}.json") |
| with open(context_path, "w", encoding="utf-8") as f: |
| json.dump(transcript_blocks, f, ensure_ascii=False) |
|
|
| prompt = build_smart_cut_prompt(request.instruction) |
| model_obj = get_gemini_model("flash") |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"❌ Erro no /editor/smart-cut: {e}") |
| _cleanup_temp_files(original_media_path, context_path) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| print(f"🤖 [SMART-CUT] Pedindo pro Gemini escolher os melhores trechos...") |
|
|
| async def body_stream(): |
| header_seen = False |
| citations = CitationStripper() |
| try: |
| async for kind, payload in stream_header_then_text( |
| g.client.generate_content_stream(prompt, files=[context_path], model=model_obj) |
| ): |
| if kind == "header": |
| header_seen = True |
| raw_segments = payload.get("segments") if isinstance(payload, dict) else None |
| raw_segments = raw_segments if isinstance(raw_segments, list) else [] |
| segments = snap_segments_to_transcript(raw_segments, transcript_blocks) |
| print(f"✂️ [SMART-CUT] Gemini propôs {len(raw_segments)} segmento(s); {len(segments)} passaram na validação de timestamp.") |
| if len(segments) < len(raw_segments): |
| print(f"⚠️ [SMART-CUT] {len(raw_segments) - len(segments)} segmento(s) descartado(s) — start/end não bateu com nenhum bloco real da transcrição (tolerância 0.35s). Possível timestamp alucinado. Bruto: {raw_segments}") |
| for s in segments: |
| print(f" MANTIDO [{s['start']:.2f} - {s['end']:.2f}] \"{s['text']}\" — motivo: {s['reason']}") |
| yield json.dumps({"segments": segments}, ensure_ascii=False) + "\n" |
| else: |
| cleaned = citations.feed(payload) |
| if cleaned: |
| yield cleaned |
| tail = citations.flush() |
| if tail: |
| yield tail |
| except Exception as e: |
| print(f"❌ Erro no streaming de /editor/smart-cut: {e}") |
| if not header_seen: |
| yield '{"segments": []}\n' |
| yield "Desculpe, tive um problema técnico ao analisar a fala. Tente de novo." |
| finally: |
| _cleanup_temp_files(original_media_path, context_path) |
|
|
| return StreamingResponse(body_stream(), media_type="text/plain; charset=utf-8", headers=_NO_BUFFER_HEADERS) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| @router.post("/detect-pauses") |
| async def editor_detect_pauses(request: DetectPausesRequest): |
| if request.window_end <= request.window_start: |
| raise HTTPException(status_code=400, detail="Invalid time window.") |
|
|
| original_media_path = None |
| try: |
| print(f"🔇 [DETECT-PAUSES] video_url={request.video_url} | janela=[{request.window_start:.2f}, {request.window_end:.2f}]") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _, _, processed_audio_url, word_level, original_media_path = await get_groq_srt_base( |
| request.video_url, time_start=request.window_start, time_end=request.window_end, |
| temperature=0.0, |
| ) |
|
|
| audio_log_url = processed_audio_url.replace("/static/", "/static/processed/", 1) if processed_audio_url else None |
| print(f"🔊 [DETECT-PAUSES] Áudio processado (o que o Whisper de fato ouviu): {audio_log_url}") |
|
|
| |
| |
| |
| |
| word_level_absolute = shift_srt_timestamps(word_level, request.window_start) |
| words = parse_srt(word_level_absolute) |
| print(f"🕒 [DETECT-PAUSES] {len(words)} palavra(s) reconhecida(s) na janela (tempo absoluto):") |
| for w in words: |
| print(f" [{w['start']:.2f} - {w['end']:.2f}] {w['text']}") |
|
|
| return JSONResponse(content={ |
| "words": [{"start": w["start"], "end": w["end"], "text": w["text"]} for w in words], |
| }) |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"❌ Erro no /editor/detect-pauses: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
| finally: |
| _cleanup_temp_files(original_media_path) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def build_analyze_media_prompt(instruction: str, has_transcript: bool, sibling_elements: list) -> str: |
| if has_transcript: |
| transcript_note = ( |
| "Uma TRANSCRIÇÃO exata da fala (formato SRT, tempos relativos ao " |
| "início do trecho analisado) também está anexada como arquivo de " |
| "texto — use-a pra citar palavras exatas, mas sua análise NÃO deve " |
| "se limitar a ela: o outro arquivo anexado é o vídeo/áudio real, e " |
| "você deve efetivamente assisti-lo/ouvi-lo pra descrever o que NÃO " |
| "está na transcrição (quem aparece, cenário, ações, expressões, tom " |
| "de voz, qualidade de imagem/áudio, música de fundo, efeitos " |
| "sonoros, texto na tela, etc)." |
| ) |
| else: |
| transcript_note = ( |
| "Não foi possível transcrever nenhuma fala nesse trecho (silêncio, " |
| "sem diálogo, ou só música/ruído) — baseie sua análise inteiramente " |
| "no que você vê e ouve no arquivo de mídia anexado." |
| ) |
|
|
| sibling_note = "" |
| if sibling_elements: |
| sibling_txt = json.dumps(sibling_elements, ensure_ascii=False) |
| sibling_note = ( |
| "\n\nCONTEXTO ADICIONAL (isto NÃO está no arquivo anexado): os " |
| "elementos a seguir também estão ativos no MESMO intervalo de " |
| "tempo deste clipe, no projeto do editor — podem estar " |
| "sobrepostos, lado a lado, ou em outra camada por cima/atrás do " |
| "que você está vendo/ouvindo. Use isso só como contexto de " |
| "composição ao responder (ex: 'nesse trecho também aparece um " |
| "texto dizendo X sobreposto ao vídeo') — você não está vendo " |
| f"esses elementos de verdade dentro do arquivo anexado:\n{sibling_txt}" |
| ) |
|
|
| return f""" |
| Você é um editor de vídeo profissional analisando um clipe de vídeo ou áudio |
| REAL, anexado a esta mensagem — assista/ouça o arquivo anexado com atenção, |
| frame a frame quando relevante, antes de responder. |
| |
| {transcript_note}{sibling_note} |
| |
| PEDIDO DO USUÁRIO SOBRE ESSE CLIPE: |
| "{instruction}" |
| |
| SUA TAREFA: responder ao pedido do usuário com o máximo de detalhe e precisão |
| possível, cobrindo tudo que for relevante que você observou no arquivo real |
| (visual e/ou sonoro) — não generalize nem invente o que não conseguir |
| observar; se algo não for possível determinar a partir do arquivo, diga isso |
| em vez de adivinhar. |
| |
| FORMATO DE RESPOSTA (CRÍTICO): responda DIRETO em texto/markdown — SEM JSON, |
| SEM bloco de código, SEM qualquer envoltório, só a análise em si. Pode (e |
| deve, quando fizer sentido) usar **negrito**, listas com "-", cabeçalhos com |
| "##" — isso é renderizado de verdade. Vá direto ao ponto, sem preâmbulo tipo |
| "Claro, aqui está a análise:". |
| """ |
|
|
|
|
| @router.post("/analyze-media") |
| async def editor_analyze_media( |
| file: UploadFile = File(...), |
| window_start: float = Form(...), |
| window_end: float = Form(...), |
| instruction: str = Form(...), |
| |
| |
| |
| |
| context: str = Form(default="[]"), |
| ): |
| if not hasattr(g, "client") or not g.client: |
| raise HTTPException(status_code=500, detail="Gemini client is not initialized") |
| if window_end <= window_start: |
| raise HTTPException(status_code=400, detail="Invalid time window.") |
|
|
| upload_path = None |
| gemini_filepath = None |
| context_path = None |
| try: |
| temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "temp") |
| os.makedirs(temp_dir, exist_ok=True) |
| ext = os.path.splitext(file.filename or "")[1] or ".mp4" |
| upload_path = os.path.join(temp_dir, f"analyze_media_upload_{uuid.uuid4().hex[:8]}{ext}") |
| with open(upload_path, "wb") as f: |
| f.write(await file.read()) |
|
|
| try: |
| sibling_elements = json.loads(context) |
| except Exception: |
| sibling_elements = [] |
| if not isinstance(sibling_elements, list): |
| sibling_elements = [] |
|
|
| print(f"🔍 [ANALYZE-MEDIA] upload={file.filename!r} ({os.path.getsize(upload_path)} bytes) | janela=[{window_start:.2f}, {window_end:.2f}] | instrução={instruction!r} | {len(sibling_elements)} elemento(s) irmão(s)") |
|
|
| srt_base, _, processed_audio_url, _word_level, gemini_filepath = await get_groq_srt_base( |
| "", time_start=window_start, time_end=window_end, local_path=upload_path, |
| ) |
|
|
| |
| |
| audio_log_url = processed_audio_url.replace("/static/", "/static/processed/", 1) if processed_audio_url else None |
| print(f"🔊 [ANALYZE-MEDIA] Áudio processado (o que o Whisper de fato ouviu): {audio_log_url}") |
|
|
| |
| |
| has_transcript = bool(srt_base and srt_base.strip()) |
| print(f"🎙️ [ANALYZE-MEDIA] Transcrição ({'com' if has_transcript else 'sem'} fala reconhecida):\n{srt_base}") |
|
|
| files_to_attach = [gemini_filepath] |
| if has_transcript: |
| context_path = os.path.join(temp_dir, f"analyze_media_transcript_{uuid.uuid4().hex[:8]}.srt") |
| with open(context_path, "w", encoding="utf-8") as f: |
| f.write(srt_base) |
| files_to_attach.append(context_path) |
|
|
| prompt = build_analyze_media_prompt(instruction, has_transcript, sibling_elements) |
| model_obj = get_gemini_model("flash") |
| except HTTPException: |
| raise |
| except Exception as e: |
| print(f"❌ Erro no /editor/analyze-media: {e}") |
| _cleanup_temp_files(upload_path, gemini_filepath, context_path) |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| print(f"🤖 [ANALYZE-MEDIA] Pedindo pro Gemini assistir/ouvir e analisar (arquivos anexados: {len(files_to_attach)})...") |
|
|
| async def body_stream(): |
| citations = CitationStripper() |
| try: |
| async for out in g.client.generate_content_stream(prompt, files=files_to_attach, model=model_obj): |
| if out.text_delta: |
| cleaned = citations.feed(out.text_delta) |
| if cleaned: |
| yield cleaned |
| tail = citations.flush() |
| if tail: |
| yield tail |
| except Exception as e: |
| print(f"❌ Erro no streaming de /editor/analyze-media: {e}") |
| yield "\n\nDesculpe, tive um problema técnico ao analisar esse trecho. Tente de novo." |
| finally: |
| _cleanup_temp_files(upload_path, gemini_filepath, context_path) |
|
|
| return StreamingResponse(body_stream(), media_type="text/plain; charset=utf-8", headers=_NO_BUFFER_HEADERS) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _probe_video_metadata(video_path: str) -> Dict[str, Any]: |
| """Real duration/width/height read directly off the LOCAL file via |
| ffprobe (already installed alongside ffmpeg — see the Dockerfile) — used |
| instead of trusting materializeProject.ts's client-side probe alone, |
| which reads the same info over HTTP from the hosted URL and can report a |
| wrong/unusable duration for some sources: a moov atom near the end of |
| the file, or a host that doesn't support Range requests, both make a |
| browser <video> report `duration: Infinity` on 'loadedmetadata' with no |
| reliable way to recover it client-side (same MP4 "faststart" limitation |
| documented for src/scrubThumbnails/thumbnailWorker.ts in CLAUDE.md, from |
| a different angle). ffprobe reads bytes off disk directly, so none of |
| that applies. Returns {} on any failure — materializeProject.ts already |
| has its own client-side probe as a fallback for that case, this is only |
| ever a better source when it succeeds, never a hard requirement.""" |
| try: |
| result = subprocess.run( |
| ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", video_path], |
| capture_output=True, text=True, timeout=30, |
| ) |
| if result.returncode != 0: |
| return {} |
| info = json.loads(result.stdout) |
| duration = float((info.get("format") or {}).get("duration") or 0) |
| video_stream = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), None) |
| width = int(video_stream.get("width") or 0) if video_stream else 0 |
| height = int(video_stream.get("height") or 0) if video_stream else 0 |
| out: Dict[str, Any] = {} |
| if duration > 0: |
| out["duration"] = duration |
| if width > 0 and height > 0: |
| out["width"] = width |
| out["height"] = height |
| return out |
| except Exception as e: |
| print(f"⚠️ ffprobe failed for {video_path}: {e}") |
| return {} |
|
|
|
|
| def _next_project_title() -> str: |
| """"Project #N" where N is the count of existing rows + 1 — same scheme |
| createBlankProject/createProjectWithVideo (projectsApi.ts) use client-side |
| for the two non-AI creation paths, so every project gets this label |
| regardless of how it was created. Not a guaranteed-unique sequence (two |
| rows could theoretically land on the same N if created at the exact same |
| instant, or a number could be reused after a delete) — acceptable for a |
| small trusted team's editor, not a public multi-tenant product.""" |
| res = g.supabase_editor.table("projects").select("id", count="exact").execute() |
| return f"Project #{(res.count or 0) + 1}" |
|
|
|
|
| def build_template_choice_prompt(title: str, description: str, transcript_srt: str, context: Optional[str]) -> str: |
| """Segunda chamada ao Gemini, pequena e só em TEXTO (não reanexa o vídeo — |
| título/descrição/transcrição já dão sinal suficiente, e é bem mais barata |
| assim). Deliberadamente SEPARADA do prompt de metadados |
| (generate_metadata_internal, em media.py) em vez de acrescentado a ele — |
| aquele prompt já é usado (e afinado) pelo plottie-editor também, e não |
| deve ganhar um campo que só faz sentido pra este editor.""" |
| has_transcript = bool(transcript_srt and transcript_srt.strip()) |
| transcript_block = f"TRANSCRIÇÃO:\n{transcript_srt}" if has_transcript else "Sem fala reconhecida." |
| context_block = f"\nCONTEXTO FORNECIDO PELO USUÁRIO:\n{context.strip()}\n" if context and context.strip() else "" |
| catalog_lines = "\n".join( |
| f"- \"{t['id']}\" ({t['name']}): campos editáveis = {', '.join(f['label'] for f in t['editableFields'])}" |
| for t in TEMPLATE_CATALOG |
| ) |
|
|
| return f""" |
| Você está escolhendo o LAYOUT de um vídeo que já tem título e descrição |
| prontos, num editor de vídeo de história/narrativa estilo documentário |
| narrado ("VOX") e conteúdo curto de redes sociais. |
| |
| TÍTULO: {title} |
| DESCRIÇÃO: {description or "(vazia)"} |
| {transcript_block} |
| {context_block} |
| |
| Escolha UM "template_id" dentre: |
| {catalog_lines} |
| |
| REGRA — O PADRÃO É "default-title-bar" (só o título, sem foto, sem descrição |
| na tela). SÓ desvie disso nos dois casos abaixo, e raramente: |
| |
| 1. Use uma variante "*-description" (default-title-description ou |
| default-image-title-description) SOMENTE quando o título sozinho NÃO |
| carrega uma informação essencial sem a qual a história não faz sentido — |
| algo que precisa de uma frase extra pra "cair a ficha". Isso é RARO. |
| Exemplo de quando usar: título "Imagina o peso de ganhar o Emmy com |
| apenas 15 anos" + descrição "Owen Cooper fez história com sua atuação na |
| série 'Adolescência'" — sem a descrição, ninguém sabe quem é ou por que |
| isso importa. Na maioria dos vídeos a descrição só repetiria/narraria o |
| que já está no vídeo — nesses casos NÃO use uma variante com descrição, |
| mesmo que uma descrição tenha sido gerada. |
| 2. Use uma variante "image-title" (default-image-title ou |
| default-image-title-description) SOMENTE quando o vídeo é sobre uma |
| pessoa/objeto/lugar específico e reconhecível que uma FOTO fixa |
| representaria melhor do que o próprio vídeo rodando atrás do título. |
| Também raro — a maioria fica melhor só com o vídeo de fundo. |
| |
| Na dúvida entre duas opções, prefira sempre a mais simples (menos elementos |
| na tela). |
| |
| FORMATO DE RESPOSTA (ESTRITAMENTE JSON, sem markdown ao redor): |
| {{"template_id": "..."}} |
| """.strip() |
|
|
|
|
| async def process_project_background(project_id: str, video_url: str, context: Optional[str]) -> None: |
| if not g.supabase_editor: |
| print("❌ Supabase (video editor) client not initialized. Cannot process project.") |
| return |
|
|
| gemini_filepath = None |
|
|
| def _update(payload: dict) -> None: |
| g.supabase_editor.table("projects").update(payload).eq("id", project_id).execute() |
|
|
| try: |
| _update({"status": "processing"}) |
|
|
| |
| |
| print(f"🎙️ [CREATE-PROJECT][{project_id}] Transcrevendo {video_url}...") |
| srt_base, _, _processed_audio_url, word_level, gemini_filepath = await get_groq_srt_base(video_url) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| video_meta = _probe_video_metadata(gemini_filepath) if gemini_filepath else {} |
|
|
| while g.client is None: |
| await asyncio.sleep(1) |
| model_obj = get_gemini_model("flash") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| cleaned_srt, translated_word_level = "", "" |
| if srt_base and srt_base.strip(): |
| try: |
| print(f"🌎 [CREATE-PROJECT][{project_id}] Traduzindo legenda pra pt-BR...") |
| default_caption_context = """ |
| AVALIE O CONTEXTO GLOBAL DO VÍDEO. |
| Se o vídeo inteiro for SOBRE música (um videoclipe, uma apresentação musical, alguém cantando de propósito), VOCÊ DEVE legendar as letras da música usando ♪. |
| PORÉM, se for um vídeo comum (pessoas conversando, entrevista, filme) e tocar uma música de fundo ou no encerramento, IGNORE A MÚSICA COMPLETAMENTE. Não legende a música. |
| PRIORIZE a escrita/texto visível no vídeo (OCR). Se houver discrepância entre o áudio e o texto escrito no vídeo, use o que está no vídeo. |
| NUNCA legende sons de ambiente. |
| """ |
| contexto_final = f"{default_caption_context}\n\nCONTEXTO ADICIONAL:\n{context or 'N/A'}" |
| caption_prompt = build_subtitle_translation_prompt(contexto_final, srt_base, word_level) |
| cleaned_srt, translated_word_level = await translate_srt_with_gemini( |
| caption_prompt, gemini_filepath, model_obj, srt_base, word_level, |
| ) |
| cleaned_srt = re.sub(r'[\.,]\s*♪', ' ♪', cleaned_srt) |
| translated_word_level = re.sub(r'[\.,]\s*♪', ' ♪', translated_word_level) |
| cleaned_srt = split_srt_on_terminal_punctuation(cleaned_srt, translated_word_level) |
| except Exception as e: |
| print(f"⚠️ [CREATE-PROJECT][{project_id}] Falha ao traduzir legenda (projeto continua sem legenda automática): {e}") |
| cleaned_srt, translated_word_level = "", "" |
|
|
| |
| |
| |
| |
| print(f"🤖 [CREATE-PROJECT][{project_id}] Gerando título/descrição...") |
| metadata_request = GenerateMetadataRequest(video_url=video_url, context=context or "", comments=[], return_type="all") |
| raw_metadata = await generate_metadata_internal(metadata_request) |
| item = raw_metadata[0] if isinstance(raw_metadata, list) and raw_metadata else (raw_metadata if isinstance(raw_metadata, dict) else {}) |
|
|
| |
| |
| |
| title = (item.get("title") or "Untitled project").strip() |
| description = (item.get("description") or "").strip() |
| thumbnail_url = item.get("thumbnail_url") or "" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| crop_info = item.get("crop_info") or None |
| has_real_crop = bool( |
| crop_info |
| and crop_info.get("original_width") and crop_info.get("original_height") |
| and (crop_info["width"] * crop_info["height"]) |
| < (crop_info["original_width"] * crop_info["original_height"] * 0.9) |
| ) |
|
|
| |
| |
| print(f"🎨 [CREATE-PROJECT][{project_id}] Escolhendo template...") |
| template_prompt = build_template_choice_prompt(title, description, srt_base or "", context) |
| template_response = await g.client.generate_content(template_prompt, files=[], model=model_obj) |
| template_data = extract_json_from_text(template_response.text) or {} |
| template_id = template_data.get("template_id") |
| valid_ids = {t["id"] for t in TEMPLATE_CATALOG} |
| if template_id not in valid_ids: |
| print(f"⚠️ [CREATE-PROJECT][{project_id}] template_id inválido/ausente ({template_id!r}), caindo pro default.") |
| template_id = "default-title-bar" |
|
|
| metadata: Dict[str, Any] = { |
| "title": title, |
| "description": description, |
| "templateId": template_id, |
| } |
| if context: |
| metadata["context"] = context |
| if cleaned_srt: |
| metadata["srt"] = cleaned_srt |
| metadata["srtWordLevel"] = translated_word_level |
| |
| |
| |
| |
| if thumbnail_url and template_id in ("default-image-title", "default-image-title-description"): |
| metadata["imageUrl"] = thumbnail_url |
| |
| |
| |
| |
| if video_meta.get("duration"): |
| metadata["videoDuration"] = video_meta["duration"] |
| if video_meta.get("width") and video_meta.get("height"): |
| metadata["videoWidth"] = video_meta["width"] |
| metadata["videoHeight"] = video_meta["height"] |
| |
| |
| |
| |
| |
| if has_real_crop: |
| metadata["videoCropX"] = crop_info["cropX"] |
| metadata["videoCropY"] = crop_info["cropY"] |
| metadata["videoCropWidth"] = crop_info["cropWidth"] |
| metadata["videoCropHeight"] = crop_info["cropHeight"] |
|
|
| _update({ |
| "status": "completed", |
| |
| |
| |
| |
| "metadata": metadata, |
| "thumbnail_url": thumbnail_url or None, |
| }) |
| print(f"✅ [CREATE-PROJECT][{project_id}] Concluído — template={template_id}") |
|
|
| except Exception as e: |
| import traceback |
| print(f"❌ [CREATE-PROJECT][{project_id}] Erro: {e}\n{traceback.format_exc()}") |
| try: |
| _update({"status": "failed", "error_message": str(e)}) |
| except Exception: |
| pass |
| finally: |
| _cleanup_temp_files(gemini_filepath) |
|
|
|
|
| @router.post("/create-project") |
| async def editor_create_project(request: CreateProjectRequest, background_tasks: BackgroundTasks): |
| if not g.supabase_editor: |
| raise HTTPException(status_code=500, detail="Supabase (video editor) não configurado — defina SUPABASE_URL_EDITOR/SUPABASE_SERVICE_KEY_EDITOR.") |
| if not request.video_url: |
| raise HTTPException(status_code=400, detail="video_url é obrigatório.") |
|
|
| res = g.supabase_editor.table("projects").insert({ |
| "title": _next_project_title(), |
| "status": "pending", |
| "video_url": request.video_url, |
| }).execute() |
|
|
| if not (res and res.data): |
| raise HTTPException(status_code=500, detail="Falha ao criar o projeto no Supabase.") |
|
|
| project_id = res.data[0]["id"] |
| background_tasks.add_task(process_project_background, project_id, request.video_url, request.context) |
| return {"project_id": project_id, "status": "pending"} |