habulaj commited on
Commit
bff362b
·
verified ·
1 Parent(s): ae50552

Update routers/editor.py

Browse files
Files changed (1) hide show
  1. 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
- cortar vídeo automaticamente com base na fala/transcrição, gerar ou editar
162
- imagens, mexer em áudio além do que já existe como campo de elemento,
163
- alterar o código/comportamento do próprio editor, acessar informação de
164
- fora do que foi anexado). Se o usuário pedir algo assim, ou qualquer coisa
 
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: