Update routers/editor.py
Browse files- routers/editor.py +192 -4
routers/editor.py
CHANGED
|
@@ -6,6 +6,8 @@ import json
|
|
| 6 |
import uuid
|
| 7 |
|
| 8 |
from utils.helpers import extract_json_from_text, get_gemini_model
|
|
|
|
|
|
|
| 9 |
import core.globals as g
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/editor")
|
|
@@ -24,6 +26,16 @@ class EditorChatRequest(BaseModel):
|
|
| 24 |
# build_editor_prompt below).
|
| 25 |
project: Dict[str, Any] = {}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# ── Template catalog (hand-mirrored from src/components/common/templates.ts)
|
| 29 |
# ─────────────────────────────────────────────────────────────────────────
|
|
@@ -122,6 +134,20 @@ CATÁLOGO DE AÇÕES DISPONÍVEIS (campos entre colchetes são opcionais):
|
|
| 122 |
7. insert_template — {{ "type": "insert_template", "templateId": string, ["startTime"]: number, ["overrides"]: {{ "<localId>": {{ ...campos... }} }} }}
|
| 123 |
8. group_elements — {{ "type": "group_elements", "elementIds": string[] }}
|
| 124 |
9. select_element — {{ "type": "select_element", "elementId": string }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
TEMPLATES DISPONÍVEIS (use o "id" exato em insert_template.templateId; os ids
|
| 127 |
dentro de "editableFields" podem ser usados como chave em "overrides"):
|
|
@@ -158,10 +184,11 @@ REGRAS IMPORTANTES:
|
|
| 158 |
- Cores são sempre string hex (ex: "#ff3366").
|
| 159 |
- HONESTIDADE (CRÍTICO, NÃO NEGOCIÁVEL): você só pode fazer exatamente o que
|
| 160 |
está no catálogo de ações acima — nada além disso é possível hoje (ex:
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
que você não tenha certeza de conseguir fazer com o catálogo, diga isso de
|
| 166 |
forma CLARA, HONESTA e EDUCADA no "reply" — algo como "ainda não fui
|
| 167 |
programado(a) pra fazer isso" — em vez de inventar uma solução, fingir que
|
|
@@ -223,6 +250,167 @@ async def editor_chat(request: EditorChatRequest):
|
|
| 223 |
raise HTTPException(status_code=500, detail=str(e))
|
| 224 |
finally:
|
| 225 |
if os.path.exists(context_path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
try:
|
| 227 |
os.remove(context_path)
|
| 228 |
except Exception:
|
|
|
|
| 6 |
import uuid
|
| 7 |
|
| 8 |
from utils.helpers import extract_json_from_text, get_gemini_model
|
| 9 |
+
from srt_utils import parse_srt, shift_srt_timestamps
|
| 10 |
+
from routers.media import get_groq_srt_base
|
| 11 |
import core.globals as g
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/editor")
|
|
|
|
| 26 |
# build_editor_prompt below).
|
| 27 |
project: Dict[str, Any] = {}
|
| 28 |
|
| 29 |
+
class SmartCutRequest(BaseModel):
|
| 30 |
+
video_url: str
|
| 31 |
+
# The clip's own current [videoStartOffset, videoStartOffset+duration]
|
| 32 |
+
# window in the SOURCE file's timeline — only this window is transcribed
|
| 33 |
+
# and analyzed, matching what the user actually sees/already trimmed on
|
| 34 |
+
# the timeline, not the whole source file.
|
| 35 |
+
window_start: float
|
| 36 |
+
window_end: float
|
| 37 |
+
instruction: str
|
| 38 |
+
|
| 39 |
|
| 40 |
# ── Template catalog (hand-mirrored from src/components/common/templates.ts)
|
| 41 |
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
| 134 |
7. insert_template — {{ "type": "insert_template", "templateId": string, ["startTime"]: number, ["overrides"]: {{ "<localId>": {{ ...campos... }} }} }}
|
| 135 |
8. group_elements — {{ "type": "group_elements", "elementIds": string[] }}
|
| 136 |
9. select_element — {{ "type": "select_element", "elementId": string }}
|
| 137 |
+
10. smart_cut_video — {{ "type": "smart_cut_video", "elementId": string, "instruction": string }}
|
| 138 |
+
Use SOMENTE quando o pedido for pra CORTAR/EDITAR um elemento de vídeo
|
| 139 |
+
(type "image" com mediaType "video" no arquivo anexado) com base na FALA
|
| 140 |
+
dele — ex: "corta esse vídeo e deixa só as melhores falas", "tira as
|
| 141 |
+
partes de enrolação da entrevista", "deixa só os melhores momentos".
|
| 142 |
+
"elementId" tem que ser um elemento de vídeo real do arquivo anexado
|
| 143 |
+
(nunca um `refId` de um elemento que você acabou de criar). "instruction"
|
| 144 |
+
é o pedido do usuário sobre COMO cortar, repassado da forma mais fiel
|
| 145 |
+
possível. Essa ação é DIFERENTE de todas as outras: não é aplicada na
|
| 146 |
+
hora — o editor ainda vai transcrever a fala e analisar o que manter,
|
| 147 |
+
o que pode levar cerca de um minuto. Por isso, quando usar essa ação,
|
| 148 |
+
seu "reply" deve deixar isso claro (ex: "Vou ouvir o vídeo e separar as
|
| 149 |
+
melhores falas — isso pode levar um minuto."), nunca afirmar que o corte
|
| 150 |
+
já foi feito. No máximo UMA ação smart_cut_video por resposta.
|
| 151 |
|
| 152 |
TEMPLATES DISPONÍVEIS (use o "id" exato em insert_template.templateId; os ids
|
| 153 |
dentro de "editableFields" podem ser usados como chave em "overrides"):
|
|
|
|
| 184 |
- Cores são sempre string hex (ex: "#ff3366").
|
| 185 |
- HONESTIDADE (CRÍTICO, NÃO NEGOCIÁVEL): você só pode fazer exatamente o que
|
| 186 |
está no catálogo de ações acima — nada além disso é possível hoje (ex:
|
| 187 |
+
gerar ou editar imagens, mexer em áudio além do que já existe como campo
|
| 188 |
+
de elemento, alterar o código/comportamento do próprio editor, acessar
|
| 189 |
+
informação de fora do que foi anexado). Cortar vídeo com base na fala JÁ é
|
| 190 |
+
possível — ver ação 10 (smart_cut_video) — não trate isso como limitação.
|
| 191 |
+
Se o usuário pedir algo fora do catálogo, ou qualquer coisa
|
| 192 |
que você não tenha certeza de conseguir fazer com o catálogo, diga isso de
|
| 193 |
forma CLARA, HONESTA e EDUCADA no "reply" — algo como "ainda não fui
|
| 194 |
programado(a) pra fazer isso" — em vez de inventar uma solução, fingir que
|
|
|
|
| 250 |
raise HTTPException(status_code=500, detail=str(e))
|
| 251 |
finally:
|
| 252 |
if os.path.exists(context_path):
|
| 253 |
+
try:
|
| 254 |
+
os.remove(context_path)
|
| 255 |
+
except Exception:
|
| 256 |
+
pass
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ── Smart video cut (Fase 2) ────────────────────────────────────────────────
|
| 260 |
+
# Groq Whisper transcreve (mesma get_groq_srt_base já usada por /subtitle);
|
| 261 |
+
# Gemini decide QUAIS trechos manter, olhando só o texto/timestamps — nunca
|
| 262 |
+
# assiste ao vídeo aqui, então essa chamada é rápida/barata.
|
| 263 |
+
|
| 264 |
+
def build_smart_cut_prompt(instruction: str) -> str:
|
| 265 |
+
return f"""
|
| 266 |
+
Você é um editor de vídeo profissional. Você recebe a TRANSCRIÇÃO de um
|
| 267 |
+
trecho de vídeo (anexada como JSON: uma lista de blocos, cada um com "start"
|
| 268 |
+
e "end" em SEGUNDOS — tempo absoluto do arquivo fonte — e "text", a fala
|
| 269 |
+
naquele intervalo) e o PEDIDO do usuário sobre como cortar esse vídeo.
|
| 270 |
+
|
| 271 |
+
SUA TAREFA: escolher quais blocos (um ou vários, não precisam ser
|
| 272 |
+
consecutivos) devem ser MANTIDOS no corte final, de acordo com o pedido.
|
| 273 |
+
Tudo que não for escolhido é descartado.
|
| 274 |
+
|
| 275 |
+
REGRAS (CRÍTICAS):
|
| 276 |
+
- Você só pode usar OS TIMESTAMPS REAIS da transcrição anexada. NUNCA invente
|
| 277 |
+
ou arredonde um valor — cada trecho escolhido tem que começar e terminar
|
| 278 |
+
EXATAMENTE no "start"/"end" de um ou mais blocos consecutivos do arquivo.
|
| 279 |
+
- Os trechos escolhidos devem estar em ORDEM CRONOLÓGICA e NUNCA se
|
| 280 |
+
sobrepor.
|
| 281 |
+
- Prefira POUCOS trechos bem escolhidos a muitos trechos curtos picados.
|
| 282 |
+
- Cada trecho deve fazer sentido sozinho, como uma afirmação/ideia completa
|
| 283 |
+
— nunca corte no meio de uma frase.
|
| 284 |
+
- Se o pedido for vago (ex: "as melhores partes", "os melhores momentos"),
|
| 285 |
+
use julgamento editorial: priorize afirmações fortes, conclusivas, com
|
| 286 |
+
informação ou emoção real — evite perguntas do entrevistador, hesitação,
|
| 287 |
+
saudação/introdução genérica, repetição.
|
| 288 |
+
- Se a transcrição não tiver nada que se destaque claramente pro pedido, é
|
| 289 |
+
válido escolher poucos trechos, ou até nenhum — nunca force uma
|
| 290 |
+
quantidade só pra preencher.
|
| 291 |
+
|
| 292 |
+
PEDIDO DO USUÁRIO:
|
| 293 |
+
"{instruction}"
|
| 294 |
+
|
| 295 |
+
Responda ESTRITAMENTE em JSON, sem markdown, sem texto fora do JSON:
|
| 296 |
+
{{
|
| 297 |
+
"segments": [
|
| 298 |
+
{{ "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" }}
|
| 299 |
+
],
|
| 300 |
+
"summary": "um resumo curto (1-2 frases), em texto normal, do que foi mantido/cortado — isso vai aparecer direto pro usuário no chat"
|
| 301 |
+
}}
|
| 302 |
+
|
| 303 |
+
Mande APENAS o JSON.
|
| 304 |
+
"""
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def snap_segments_to_transcript(
|
| 308 |
+
segments: list,
|
| 309 |
+
transcript_blocks: list,
|
| 310 |
+
tolerance: float = 0.35,
|
| 311 |
+
max_segments: int = 40,
|
| 312 |
+
) -> list:
|
| 313 |
+
"""Defesa contra timestamp alucinado: cada segmento que o Gemini devolveu
|
| 314 |
+
só é aceito se o "start"/"end" dele estiver bem perto (`tolerance`
|
| 315 |
+
segundos) do start/end de um bloco REAL da transcrição — nesse caso o
|
| 316 |
+
valor é substituído pelo valor EXATO do bloco real, nunca o número cru
|
| 317 |
+
que o modelo escreveu. Fora da tolerância, o segmento inteiro é
|
| 318 |
+
descartado. Diferente de um erro de texto (Fase 1), um timestamp errado
|
| 319 |
+
aqui corrompe o vídeo cortado, então essa validação não é opcional.
|
| 320 |
+
Depois: ordena por tempo, descarta sobreposição, e limita a
|
| 321 |
+
`max_segments` (segurança contra uma resposta anormalmente picada).
|
| 322 |
+
"""
|
| 323 |
+
if not transcript_blocks:
|
| 324 |
+
return []
|
| 325 |
+
|
| 326 |
+
valid = []
|
| 327 |
+
for seg in segments:
|
| 328 |
+
if not isinstance(seg, dict):
|
| 329 |
+
continue
|
| 330 |
+
start, end = seg.get("start"), seg.get("end")
|
| 331 |
+
if not isinstance(start, (int, float)) or not isinstance(end, (int, float)) or end <= start:
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
+
nearest_start_block = min(transcript_blocks, key=lambda b: abs(b["start"] - start))
|
| 335 |
+
nearest_end_block = min(transcript_blocks, key=lambda b: abs(b["end"] - end))
|
| 336 |
+
if abs(nearest_start_block["start"] - start) > tolerance or abs(nearest_end_block["end"] - end) > tolerance:
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
valid.append({
|
| 340 |
+
"start": nearest_start_block["start"],
|
| 341 |
+
"end": nearest_end_block["end"],
|
| 342 |
+
"text": seg.get("text") or "",
|
| 343 |
+
"reason": seg.get("reason") or "",
|
| 344 |
+
})
|
| 345 |
+
|
| 346 |
+
valid.sort(key=lambda s: s["start"])
|
| 347 |
+
result = []
|
| 348 |
+
last_end = -1.0
|
| 349 |
+
for s in valid:
|
| 350 |
+
if s["start"] < last_end:
|
| 351 |
+
continue
|
| 352 |
+
result.append(s)
|
| 353 |
+
last_end = s["end"]
|
| 354 |
+
if len(result) >= max_segments:
|
| 355 |
+
break
|
| 356 |
+
return result
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@router.post("/smart-cut")
|
| 360 |
+
async def editor_smart_cut(request: SmartCutRequest):
|
| 361 |
+
if not hasattr(g, "client") or not g.client:
|
| 362 |
+
raise HTTPException(status_code=500, detail="Gemini client is not initialized")
|
| 363 |
+
if request.window_end <= request.window_start:
|
| 364 |
+
raise HTTPException(status_code=400, detail="Invalid time window.")
|
| 365 |
+
|
| 366 |
+
original_media_to_delete = None
|
| 367 |
+
context_path = None
|
| 368 |
+
try:
|
| 369 |
+
srt_base, _, _audio_url, _word_level, original_media_path = await get_groq_srt_base(
|
| 370 |
+
request.video_url, time_start=request.window_start, time_end=request.window_end,
|
| 371 |
+
)
|
| 372 |
+
original_media_to_delete = original_media_path
|
| 373 |
+
|
| 374 |
+
# get_groq_srt_base's timestamps are relative to the TRIMMED window
|
| 375 |
+
# (0-based) — shift back to the source file's own absolute time, the
|
| 376 |
+
# same space Element.videoStartOffset already uses, so the frontend
|
| 377 |
+
# can apply "start"/"end" straight through with no extra math.
|
| 378 |
+
srt_absolute = shift_srt_timestamps(srt_base, request.window_start)
|
| 379 |
+
transcript_blocks = parse_srt(srt_absolute)
|
| 380 |
+
if not transcript_blocks:
|
| 381 |
+
return {"segments": [], "summary": "I couldn't find any speech in this clip to analyze."}
|
| 382 |
+
|
| 383 |
+
temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static", "temp")
|
| 384 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 385 |
+
context_path = os.path.join(temp_dir, f"smart_cut_transcript_{uuid.uuid4().hex[:8]}.json")
|
| 386 |
+
with open(context_path, "w", encoding="utf-8") as f:
|
| 387 |
+
json.dump(transcript_blocks, f, ensure_ascii=False)
|
| 388 |
+
|
| 389 |
+
print(f"🎬 [EDITOR] Analisando fala pra corte inteligente...")
|
| 390 |
+
prompt = build_smart_cut_prompt(request.instruction)
|
| 391 |
+
model_obj = get_gemini_model("flash")
|
| 392 |
+
response = await g.client.generate_content(prompt, files=[context_path], model=model_obj)
|
| 393 |
+
|
| 394 |
+
data = extract_json_from_text(response.text)
|
| 395 |
+
if not data or not isinstance(data, dict):
|
| 396 |
+
return {"segments": [], "summary": "Couldn't make sense of the speech analysis — try again."}
|
| 397 |
+
|
| 398 |
+
raw_segments = data.get("segments")
|
| 399 |
+
segments = snap_segments_to_transcript(raw_segments if isinstance(raw_segments, list) else [], transcript_blocks)
|
| 400 |
+
summary = data.get("summary") or ""
|
| 401 |
+
|
| 402 |
+
return {"segments": segments, "summary": summary}
|
| 403 |
+
|
| 404 |
+
except Exception as e:
|
| 405 |
+
print(f"❌ Erro no /editor/smart-cut: {e}")
|
| 406 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 407 |
+
finally:
|
| 408 |
+
if original_media_to_delete and os.path.exists(original_media_to_delete) and "static" in original_media_to_delete:
|
| 409 |
+
try:
|
| 410 |
+
os.unlink(original_media_to_delete)
|
| 411 |
+
except Exception:
|
| 412 |
+
pass
|
| 413 |
+
if context_path and os.path.exists(context_path):
|
| 414 |
try:
|
| 415 |
os.remove(context_path)
|
| 416 |
except Exception:
|