Ao dividir _shared.py em admin/api/*.py ontem, o cálculo `Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajustar para o nível de diretório novo. No arquivo original (admin/models_api.py, direto em admin/) dois `.parent` chegavam na raiz do repo. Em admin/api/shared.py, um nível mais fundo, dois `.parent` param em admin/ — e admin/code nunca existiu. sys.path nunca recebia code/, então toda ação que passa por `server` (analisar voz, aplicar decisões) crashava o app com ModuleNotFoundError: server_tools. O bug sobreviveu a duas rodadas de validação da sessão anterior — lint zero, 1454 testes verdes, comando testado manualmente pela ponte — porque todos rodam num venv com install editável (__editable__.fcp_mcp_server.pth) que já deixa fcpxml/server_tools importáveis por conta própria, mascarando qualquer erro no cálculo manual de sys.path. Só o app real, no fallback sem uv, expõe o bug. Correção: o cálculo de sys.path sai de cada módulo de comando (estava duplicado em nove arquivos) e passa a existir uma única vez em admin/api/__init__.py, que roda antes de qualquer submódulo — nenhum precisa mais da própria cópia. O teste de regressão precisou de duas tentativas pelo mesmo motivo do bug: a primeira versão também passava com o bug presente, por rodar no mesmo venv "de sorte". Só ficou confiável isolando um subprocess que remove site-packages do sys.path antes de importar — confirmado nos dois sentidos, falha com o bug reintroduzido e passa com a correção (TestCodeDirResolution). Detalhe completo, incluindo por que o comando manual não pegou: Engine/docs/05_EXPERIENCIAS.md #25. Lint zerado, 1457 testes passando (3 novos), app compilado. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
4.0 KiB
Python
108 lines
4.0 KiB
Python
"""Zoom (punch-in): por janela, por clipe e por trecho da transcrição.
|
|
|
|
Extraído de models_api.py — a tabela de comandos segue lá.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from . import shared
|
|
from .shared import (
|
|
_derived_output,
|
|
_load_cached_transcript,
|
|
_transcript_json_path,
|
|
)
|
|
|
|
|
|
def cmd_add_zoom(args: dict) -> int:
|
|
"""Add an ease-in/ease-out punch-in zoom to one clip."""
|
|
path = str(args.get("path", ""))
|
|
clip_id = str(args.get("clip_id", "")).strip()
|
|
if not path or not Path(path).exists():
|
|
shared.emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
|
|
return 1
|
|
if not clip_id:
|
|
shared.emit({"ok": False, "error": "Informe o nome do clipe."})
|
|
return 1
|
|
try:
|
|
from server import handle_add_zoom
|
|
|
|
output = _derived_output(path, "_zoom", args)
|
|
contents = asyncio.run(handle_add_zoom({**args, "filepath": path, "output_path": output}))
|
|
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
|
|
if not Path(output).exists():
|
|
shared.emit({"ok": False, "error": message})
|
|
return 1
|
|
shared.emit({"ok": True, "path": output, "message": message})
|
|
return 0
|
|
except Exception as exc:
|
|
shared.emit({"ok": False, "error": str(exc)})
|
|
return 1
|
|
|
|
def cmd_zoom_clips(args: dict) -> int:
|
|
"""Return timeline clips with enough identity for the zoom picker."""
|
|
path = Path(str(args.get("path", "")))
|
|
output_dir = str(args.get("output_dir", "")).strip()
|
|
if not path.exists():
|
|
shared.emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
|
|
return 1
|
|
try:
|
|
from server import _require_timeline
|
|
|
|
_, timeline = _require_timeline(str(path))
|
|
clips = []
|
|
for index, clip in enumerate(timeline.clips):
|
|
media = clip.media_path or ""
|
|
cached = _load_cached_transcript(_transcript_json_path(media, output_dir)) if media else None
|
|
clips.append({
|
|
"id": f"{index}:{clip.start.seconds:.6f}",
|
|
"index": index,
|
|
"name": clip.name,
|
|
"start": clip.start.seconds,
|
|
"duration": clip.duration_seconds,
|
|
"media": Path(media).name if media else "",
|
|
"preview": ((cached or {}).get("text", "") or "")[:180],
|
|
"has_transcript": cached is not None,
|
|
})
|
|
shared.emit({"ok": True, "clips": clips})
|
|
return 0
|
|
except Exception as exc:
|
|
shared.emit({"ok": False, "error": str(exc)})
|
|
return 1
|
|
|
|
def cmd_zoom_segments(args: dict) -> int:
|
|
"""Return sentence/word ranges for one timeline clip."""
|
|
path = Path(str(args.get("path", "")))
|
|
output_dir = str(args.get("output_dir", "")).strip()
|
|
try:
|
|
from server import _require_timeline
|
|
|
|
_, timeline = _require_timeline(str(path))
|
|
index = int(args.get("index", -1))
|
|
if index < 0 or index >= len(timeline.clips):
|
|
raise ValueError("Clipe selecionado não existe.")
|
|
clip = timeline.clips[index]
|
|
if not clip.media_path:
|
|
raise ValueError("Este clipe não possui mídia associada.")
|
|
data = _load_cached_transcript(_transcript_json_path(clip.media_path, output_dir))
|
|
if data is None:
|
|
shared.emit({"ok": True, "segments": [], "message": "Transcreva este clipe primeiro."})
|
|
return 0
|
|
segments = []
|
|
for number, segment in enumerate(data.get("segments", [])):
|
|
text = str(segment.get("text", "")).strip()
|
|
if text:
|
|
segments.append({
|
|
"id": number,
|
|
"start": float(segment.get("start", 0)),
|
|
"end": float(segment.get("end", 0)),
|
|
"text": text,
|
|
})
|
|
shared.emit({"ok": True, "segments": segments})
|
|
return 0
|
|
except Exception as exc:
|
|
shared.emit({"ok": False, "error": str(exc)})
|
|
return 1
|