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>
235 lines
9.5 KiB
Python
235 lines
9.5 KiB
Python
"""Legendas: dinâmicas, comuns, SRT e as configurações de estilo.
|
|
|
|
Extraído de models_api.py — a tabela de comandos segue lá.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from fcpxml.media_intel import media_src_to_path
|
|
from fcpxml.model_manager import (
|
|
load_dynamic_subtitle_config,
|
|
load_plain_subtitle_config,
|
|
save_dynamic_subtitle_config,
|
|
save_plain_subtitle_config,
|
|
)
|
|
from fcpxml.writer import FCPXMLModifier
|
|
|
|
from . import shared
|
|
from .shared import (
|
|
_derived_output,
|
|
_emit_no_change_or_error,
|
|
_load_cached_transcript,
|
|
_transcript_json_path,
|
|
)
|
|
|
|
|
|
def cmd_generate_dynamic_subtitles(args: dict) -> int:
|
|
"""Generate word-by-word ("karaoke") caption compound clips, one per line,
|
|
using each media's cached transcript."""
|
|
path = str(args.get("path", ""))
|
|
if not path or not Path(path).exists():
|
|
shared.emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
|
|
return 1
|
|
try:
|
|
from server import handle_generate_dynamic_subtitles
|
|
|
|
output = _derived_output(path, "_dynamic_subtitles", args)
|
|
contents = asyncio.run(
|
|
handle_generate_dynamic_subtitles({**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_generate_plain_subtitles(args: dict) -> int:
|
|
"""Generate simple static editable subtitle title clips."""
|
|
path = str(args.get("path", ""))
|
|
if not path or not Path(path).exists():
|
|
shared.emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
|
|
return 1
|
|
try:
|
|
from server import handle_generate_plain_subtitles
|
|
|
|
output = _derived_output(path, "_plain_subtitles", args)
|
|
contents = asyncio.run(
|
|
handle_generate_plain_subtitles({**args, "filepath": path, "output_path": output})
|
|
)
|
|
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
|
|
if not Path(output).exists():
|
|
return _emit_no_change_or_error(path, message)
|
|
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_export_srt(args: dict) -> int:
|
|
"""Write a captions .srt synced to the edited timeline.
|
|
|
|
Each transcribed segment is mapped from its SOURCE-media timestamp to its
|
|
real TIMELINE position (``clip_offset + (seg_start - clip_source_start)``),
|
|
so captions only cover the frames that remain after cuts/silence removal —
|
|
not the whole source file. One .srt is produced per media, in timeline order.
|
|
"""
|
|
path = str(args.get("path", ""))
|
|
output_dir = str(args.get("output_dir", "")).strip()
|
|
if not path or not Path(path).exists():
|
|
shared.emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
|
|
return 1
|
|
try:
|
|
modifier = FCPXMLModifier(path)
|
|
except Exception as exc:
|
|
shared.emit({"ok": False, "error": f"Erro ao ler o projeto: {exc}"})
|
|
return 1
|
|
|
|
# Group spine clips by media so each transcript is loaded once.
|
|
by_media: dict[str, list] = {}
|
|
for _, el in modifier._iter_spine_clips():
|
|
src = modifier.resources.get(el.get("ref", ""), {}).get("src", "")
|
|
mp = media_src_to_path(src)
|
|
if not mp or not Path(mp).is_file():
|
|
continue
|
|
by_media.setdefault(mp, []).append(el)
|
|
|
|
# Never emit a caption past the end of the project — Final Cut rejects an
|
|
# SRT whose last cue overruns the timeline ("subtitle extends beyond project
|
|
# duration"). Clamp every mapped cue end to this ceiling.
|
|
timeline_total = modifier._timeline_duration().to_seconds()
|
|
|
|
srt_paths: list[str] = []
|
|
for mp, clips in by_media.items():
|
|
cached = _load_cached_transcript(_transcript_json_path(mp, output_dir))
|
|
if cached is None:
|
|
continue
|
|
segments = cached.get("segments") or []
|
|
if not segments:
|
|
continue
|
|
|
|
rows: list[tuple[float, float, str, int]] = []
|
|
for el in clips:
|
|
clip_source_start = modifier.source_file_start(el).to_seconds()
|
|
clip_duration = modifier._parse_time(el.get("duration", "0s")).to_seconds()
|
|
clip_offset = modifier._parse_time(el.get("offset", "0s")).to_seconds()
|
|
window_end = clip_source_start + clip_duration
|
|
for seg_index, seg in enumerate(segments):
|
|
seg_start = float(seg.get("start", 0.0))
|
|
seg_end = float(seg.get("end", seg_start))
|
|
text = seg.get("text", "").strip()
|
|
if not text or seg_end <= seg_start:
|
|
continue
|
|
# Intersect the complete source segment with this kept clip.
|
|
# Testing only seg_start loses speech whose first words fall in
|
|
# a removed range; interval intersection preserves the part
|
|
# that remains and avoids duplicating a segment wholesale.
|
|
source_start = max(seg_start, clip_source_start)
|
|
source_end = min(seg_end, window_end)
|
|
if source_end <= source_start:
|
|
continue
|
|
tl_start = clip_offset + (source_start - clip_source_start)
|
|
tl_end = clip_offset + (source_end - clip_source_start)
|
|
tl_start = max(0.0, min(tl_start, timeline_total))
|
|
tl_end = max(0.0, min(tl_end, timeline_total))
|
|
if tl_end > tl_start:
|
|
rows.append((tl_start, tl_end, text, seg_index))
|
|
|
|
if not rows:
|
|
continue
|
|
rows.sort(key=lambda r: (r[0], r[1], r[3]))
|
|
# Merge only pieces from the same original Whisper segment when their
|
|
# mapped intervals touch. Never merge unrelated speech or invent time.
|
|
merged: list[tuple[float, float, str, int]] = []
|
|
for row in rows:
|
|
if merged and row[3] == merged[-1][3] and row[0] <= merged[-1][1] + 0.001:
|
|
prev = merged[-1]
|
|
merged[-1] = (prev[0], max(prev[1], row[1]), prev[2], prev[3])
|
|
else:
|
|
merged.append(row)
|
|
|
|
blocks = []
|
|
for index, (s, e, text, _) in enumerate(merged, 1):
|
|
start_stamp = srt_stamp(s)
|
|
end_stamp = srt_stamp(e)
|
|
# Millisecond SRT precision can collapse a sub-millisecond span;
|
|
# omit it rather than emit an invalid zero-duration cue.
|
|
if start_stamp == end_stamp:
|
|
continue
|
|
blocks.append(f"{index}\n{start_stamp} --> {end_stamp}\n{text}\n")
|
|
if not blocks:
|
|
continue
|
|
|
|
out = (
|
|
Path(output_dir).expanduser() / f"{Path(mp).stem}_captions.srt"
|
|
if output_dir
|
|
else Path(mp).with_name(Path(mp).stem + "_captions.srt")
|
|
)
|
|
if output_dir:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
out.write_text("\n".join(blocks), encoding="utf-8")
|
|
except OSError as exc:
|
|
shared.emit({"ok": False, "error": f"Não foi possível salvar a legenda: {exc}"})
|
|
return 1
|
|
srt_paths.append(str(out))
|
|
|
|
if not srt_paths:
|
|
shared.emit({"ok": False, "error": "Nenhuma transcrição encontrada. Transcreva o projeto primeiro."})
|
|
return 1
|
|
|
|
shared.emit({"ok": True, "paths": srt_paths, "message": f"{len(srt_paths)} legenda(s) .srt sincronizada(s) com o corte."})
|
|
return 0
|
|
|
|
def srt_stamp(seconds: float) -> str:
|
|
"""Format float seconds as ``HH:MM:SS,mmm`` (SRT uses a comma).
|
|
|
|
Uses ``floor`` (not ``round``) so a timestamp never rounds up past a frame
|
|
boundary — an SRT cue ending on the last frame must not overrun the
|
|
project duration, or Final Cut flags it as extending beyond the project.
|
|
"""
|
|
ms = int((seconds if seconds > 0 else 0.0) * 1000)
|
|
h, rem = divmod(ms, 3600000)
|
|
m, rem = divmod(rem, 60000)
|
|
s, ms = divmod(rem, 1000)
|
|
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
|
|
def cmd_dynamic_subtitle_config(args: dict) -> int:
|
|
"""Read the persisted dynamic-subtitle style (font, size, color, layout)."""
|
|
shared.emit({"ok": True, **load_dynamic_subtitle_config()})
|
|
return 0
|
|
|
|
def cmd_set_dynamic_subtitle_config(args: dict) -> int:
|
|
"""Persist dynamic-subtitle style fields. Only the given fields change."""
|
|
config = save_dynamic_subtitle_config(**{
|
|
k: args.get(k) for k in (
|
|
"band_height", "block_center_y", "line_gap", "font", "font_size",
|
|
"emphasis_font", "emphasis_face", "emphasis_size",
|
|
"active_color", "emphasis_color", "text_scale",
|
|
)
|
|
})
|
|
shared.emit({"ok": True, **config})
|
|
return 0
|
|
|
|
def cmd_plain_subtitle_config(args: dict) -> int:
|
|
"""Read the persisted simple subtitle style."""
|
|
shared.emit({"ok": True, **load_plain_subtitle_config()})
|
|
return 0
|
|
|
|
def cmd_set_plain_subtitle_config(args: dict) -> int:
|
|
"""Persist simple subtitle style fields. Only the given fields change."""
|
|
config = save_plain_subtitle_config(**{
|
|
k: args.get(k) for k in (
|
|
"font", "font_size", "font_color", "max_words",
|
|
"position_y", "uppercase", "keep_punctuation", "text_scale",
|
|
)
|
|
})
|
|
shared.emit({"ok": True, **config})
|
|
return 0
|