chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# ---------------------------------------------------------------------------
# commit.command — Faz commit e push para o Gitea (G-ART)
#
# Uso:
# ./admin/commit.command # mensagem genérica
# ./admin/commit.command "sua mensagem" # mensagem customizada
#
# Requer: git configurado com remote HTTPS + token no Gitea.
# Repo: https://gitea.nacarmed.cloud/joaohenrique/gart.git
# ---------------------------------------------------------------------------
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$DIR/.." && pwd)"
cd "$PROJECT_DIR"
# ── Cores ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}==> $1${NC}"; }
ok() { echo -e "${GREEN} ✓ $1${NC}"; }
warn() { echo -e "${YELLOW} ⚠ $1${NC}"; }
erro() { echo -e "${RED} ✗ ERRO: $1${NC}" >&2; exit 1; }
# ── 1. Verificar se é um repositório git ───────────────────────────────────
if [ ! -d "$PROJECT_DIR/.git" ]; then
erro "Não é um repositório git. Execute 'git init' primeiro."
fi
# ── 2. Configurar remote se necessário ─────────────────────────────────────
REMOTE_URL="https://gitea.nacarmed.cloud/joaohenrique/gart.git"
if ! git remote get-url origin &>/dev/null; then
info "Configurando remote origin..."
git remote add origin "$REMOTE_URL"
ok "Remote adicionado: $REMOTE_URL"
else
ATUAL=$(git remote get-url origin)
# Verificar se o remote já tem token (HTTPS com credenciais)
if [[ "$ATUAL" != *"@"* ]] && [[ "$ATUAL" == *"gitea.nacarmed.cloud"* ]]; then
warn "Remote sem token de autenticação."
warn "Atual: $ATUAL"
echo ""
echo " Para autenticar, execute:"
echo " git remote set-url origin https://USUARIO:TOKEN@gitea.nacarmed.cloud/joaohenrique/gart.git"
echo ""
echo " Ou gere um token em:"
echo " https://gitea.nacarmed.cloud/-/user/settings/tokens"
echo ""
fi
fi
# ── 3. Configurar branch principal como main ───────────────────────────────
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
if [ -z "$CURRENT_BRANCH" ]; then
info "Primeiro commit — criando branch main..."
git checkout -b main 2>/dev/null || true
CURRENT_BRANCH="main"
fi
# ── 4. Verificar alterações ────────────────────────────────────────────────
info "Verificando alterações em: $PROJECT_DIR"
info "Branch: $CURRENT_BRANCH"
echo ""
# Mostrar o que será commitado (resumo)
CHANGES=$(git status --porcelain)
if [ -z "$CHANGES" ]; then
ok "Nada para commitar — repositório limpo."
exit 0
fi
echo -e "${YELLOW}Arquivos que serão commitados:${NC}"
echo "$CHANGES" | head -30
TOTAL=$(echo "$CHANGES" | wc -l | tr -d ' ')
if [ "$TOTAL" -gt 30 ]; then
echo -e "${YELLOW} ... e mais $((TOTAL - 30)) arquivo(s)${NC}"
fi
echo ""
# ── 5. Mensagem do commit ──────────────────────────────────────────────────
COMMIT_MSG="${1:-}"
USED_DESC_FILE=false
if [ -z "$COMMIT_MSG" ] && [ -s "$PROJECT_DIR/commit-desc.txt" ]; then
COMMIT_MSG="$(cat "$PROJECT_DIR/commit-desc.txt")"
USED_DESC_FILE=true
fi
COMMIT_MSG="${COMMIT_MSG:-chore: atualização geral}"
# ── 6. Adicionar e commitar ────────────────────────────────────────────────
info "Adicionando arquivos..."
git add -A
# Commit com mensagem (suporta múltiplas linhas)
SUBJECT="$(printf '%s\n' "$COMMIT_MSG" | awk 'NF{print; exit}')"
BODY="$(printf '%s\n' "$COMMIT_MSG" | sed '1d' | awk 'NF' | tr '\n' ' ' | sed 's/ *$//')"
info "Commitando..."
if [ -n "$BODY" ]; then
git commit -m "$SUBJECT" -m "$BODY"
else
git commit -m "$SUBJECT"
fi
ok "Commit realizado com sucesso."
# Limpar commit-desc.txt se foi usado
if [ "$USED_DESC_FILE" = true ]; then
: > "$PROJECT_DIR/commit-desc.txt"
fi
# ── 7. Push para o Gitea ───────────────────────────────────────────────────
info "Enviando para o Gitea (origin/$CURRENT_BRANCH)..."
if git push origin "$CURRENT_BRANCH" 2>/dev/null; then
ok "Push concluído com sucesso!"
else
warn "Push falhou. Verifique a autenticação."
echo ""
echo -e "${YELLOW}Passos para resolver:${NC}"
echo ""
echo " 1. Gere um token no Gitea:"
echo " https://gitea.nacarmed.cloud/-/user/settings/tokens"
echo ""
echo " 2. Configure o remote com o token:"
echo " git remote set-url origin https://USUARIO:TOKEN@gitea.nacarmed.cloud/joaohenrique/gart.git"
echo ""
echo " 3. Execute novamente:"
echo " ./admin/commit.command"
echo ""
fi
echo ""
ok "Processo de commit concluído."
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Commit-only — commita e envia (push) o branch atual do repositório.
# Não mexe em produção.
#
# Usage:
# ./commit.sh # mensagem genérica
# ./commit.sh "sua mensagem" # mensagem customizada
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
source "$DIR/lib/common.sh"
require_project_dir
cd "$PROJECT_DIR"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
info "Verificando alterações locais em $PROJECT_DIR (branch: $BRANCH)..."
if [ -z "$(git status --porcelain)" ]; then
ok "Nada para commitar."
exit 0
fi
# Mensagem do commit: prioridade
# 1) argumento explícito do script
# 2) conteúdo de code/commit-desc.txt (a IA mantém o resumo do que foi feito)
# 3) mensagem genérica
COMMIT_MSG="${1:-}"
USED_DESC_FILE=false
if [ -z "$COMMIT_MSG" ] && [ -s "$PROJECT_DIR/commit-desc.txt" ]; then
COMMIT_MSG="$(cat "$PROJECT_DIR/commit-desc.txt")"
USED_DESC_FILE=true
fi
COMMIT_MSG="${COMMIT_MSG:-chore: update}"
info "Commitando alterações..."
git add -A
# Se a mensagem tiver múltiplas linhas, usa a primeira como título e o resto
# como corpo (git commit -m -m).
SUBJECT="$(printf '%s\n' "$COMMIT_MSG" | awk 'NF{print; exit}')"
BODY="$(printf '%s\n' "$COMMIT_MSG" | sed '1d' | awk 'NF' | tr '\n' ' ' | sed 's/ *$//')"
if [ -n "$BODY" ]; then
git commit -m "$SUBJECT" -m "$BODY"
else
git commit -m "$SUBJECT"
fi
# Esvazia commit-desc.txt depois que a mensagem já está presa no commit, pra
# não reaparecer numa próxima chamada e virar a mensagem de um commit
# seguinte que não tem nada a ver com ela.
if [ "$USED_DESC_FILE" = true ]; then
: > "$PROJECT_DIR/commit-desc.txt"
fi
info "Enviando para o Gitea (origin/$BRANCH)..."
git push origin "$BRANCH"
ok "Commit concluído em '$BRANCH'."
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
#
# graphify.sh — Roda o graphify sobre o código-fonte deste projeto (G-ART / fcp-mcp-server).
#
# Uso:
# ./admin/graphify.sh # analisa o código real (server.py + fcpxml/)
# ./admin/graphify.sh <caminho> # analisa outro caminho
#
# Requer: graphify instalado (pip install graphifyy / uv tool install graphifyy)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
# Caminho padrão: pasta code/ (analisa server.py + fcpxml/). Pode ser sobrescrito por $1.
INPUT_PATH="${1:-$ROOT_DIR/code}"
if [ ! -e "$INPUT_PATH" ]; then
echo "ERRO: caminho não encontrado: $INPUT_PATH" >&2
exit 1
fi
echo "==> Graphify em: $INPUT_PATH"
# Detecta interpretador Python que possui o pacote graphify.
PYTHON=""
if command -v uv >/dev/null 2>&1; then
PYTHON="$(uv tool run --from graphifyy python -c 'import sys; print(sys.executable)' 2>/dev/null || true)"
fi
if [ -z "$PYTHON" ]; then
PYTHON="$(command -v python3 || command -v python)"
fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
echo "==> Instalando graphifyy..."
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy
PYTHON="$(uv tool run --from graphifyy python -c 'import sys; print(sys.executable)')"
else
"$PYTHON" -m pip install graphifyy
fi
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python','w').write(sys.executable)"
# Detecta arquivos do corpus.
echo "==> Detectando arquivos..."
"$PYTHON" -c "
import json, sys
from graphify.detect import detect
from pathlib import Path
print(json.dumps(detect(Path('$INPUT_PATH')), ensure_ascii=False))
" > graphify-out/.graphify_detect.json
# Extração estrutural (AST) — código, sem LLM.
echo "==> Extração estrutural (AST)..."
"$PYTHON" -c "
import json, sys
from pathlib import Path
from graphify.extract import collect_files, extract
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding='utf-8'))
code_files = []
for f in detect.get('files', {}).get('code', []):
p = Path(f)
code_files.extend(collect_files(p) if p.is_dir() else [p])
if code_files:
result = extract(code_files, cache_root=Path('$INPUT_PATH'))
else:
result = {'nodes': [], 'edges': [], 'input_tokens': 0, 'output_tokens': 0}
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding='utf-8')
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
"
# Corpus só de código -> semântica vazia; caso contrário o usuário roda via /graphify.
"$PYTHON" -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes': [], 'edges': [], 'hyperedges': [], 'input_tokens': 0, 'output_tokens': 0}), encoding='utf-8')
"
# Merge AST + semântica.
"$PYTHON" -c "
import json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding='utf-8'))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding='utf-8'))
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n); seen.add(n['id'])
merged = {'nodes': merged_nodes, 'edges': ast['edges'] + sem['edges'],
'hyperedges': sem.get('hyperedges', []),
'input_tokens': sem.get('input_tokens', 0), 'output_tokens': sem.get('output_tokens', 0)}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding='utf-8')
print(f'Merged: {len(merged_nodes)} nodes, {len(merged[\"edges\"])} edges')
"
# Build + cluster + report.
echo "==> Construindo grafo e clusters..."
"$PYTHON" -c "
import json
from pathlib import Path
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding='utf-8'))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding='utf-8'))
G = build_from_json(extraction, root='$INPUT_PATH', directed=False)
if G.number_of_nodes() == 0:
print('ERRO: grafo vazio - nenhum nó extraído.'); raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
questions = suggest_questions(G, communities, labels)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERRO: recusou encolher graph.json (#479).'); raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '$INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding='utf-8')
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}), encoding='utf-8')
print(f'Grafo: {G.number_of_nodes()} nós, {G.number_of_edges()} arestas, {len(communities)} comunidades')
"
# HTML interativo.
echo "==> Exportando HTML..."
"$PYTHON" -m graphify export html || graphify export html 2>/dev/null || true
echo
echo "Concluído. Saídas em:"
echo " $(pwd)/graphify-out/graph.html"
echo " $(pwd)/graphify-out/GRAPH_REPORT.md"
echo " $(pwd)/graphify-out/graph.json"
echo
read -n 1 -s -r -p "Pressione qualquer tecla para fechar..." || true
+39
View File
@@ -0,0 +1,39 @@
---
description: Roda o graphify no código-fonte (server.py + fcpxml/) e gera o grafo de conhecimento.
---
# Graphify do código
Execute o pipeline completo do graphify sobre o código-fonte do projeto
(`code/server.py` e `code/fcpxml/`), gerando o grafo de conhecimento em `graphify-out/`.
## Orientação de execução
1. **Verifique o grafo existente**: se `graphify-out/graph.json` já existir e o
código-fonte não tiver sido alterado, responda "Grafo já construído" e
ofereça `/graphify query`. Caso contrário, prossiga com uma reconstrução.
2. **Alvo**: rode o graphify sobre o código-fonte deste repositório
(G-ART / fcp-mcp-server): `code/server.py` + `code/fcpxml/`. Siga fielmente o passo
a passo do skill `graphify`:
- Step 1: garantir o interpretador Python + pacote `graphifyy`.
- Step 2: detectar arquivos do corpus.
- Step 3: extração estrutural (AST) + semântica (subagentes / Gemini).
- Step 4-5: construir o grafo, clusterizar (comunidades) e rotular.
- Step 6: gerar o HTML interativo `graph.html`.
- Step 9: salvar manifest, custo e relatório `GRAPH_REPORT.md`.
3. **Saídas esperadas** em `graphify-out/`:
- `graph.html` — visualização interativa (abrir no navegador).
- `GRAPH_REPORT.md` — relatório de auditoria.
- `graph.json` — dados crus do grafo.
4. **Entrega**: mostre do `GRAPH_REPORT.md` apenas as seções "God Nodes",
"Surprising Connections" e "Suggested Questions". Em seguida ofereça explorar
a pergunta sugerida mais interessante com `/graphify query`.
## Observações
- Se o usuário passar um caminho em `$ARGUMENTS`, use esse caminho em vez do padrão.
- `$ARGUMENTS` opcional: caminho do corpus a ser analisado (padrão o código real).
+891
View File
@@ -0,0 +1,891 @@
#!/usr/bin/env python3
"""JSON bridge between the SwiftUI app and the fcp-mcp-server Python engine.
The SwiftUI app (MacApp/) launches this script as a subprocess with a command
and optional JSON arguments, then reads a single JSON document (or
newline-delimited JSON for progress) on stdout.
Commands:
catalog
-> {"models": [{display_name, internal_name, size, storage,
accuracy, speed}], "installed": [names],
"selected": name, "models_dir": path, "installed_count": n,
"recommended": [names]}
download {"model": "small"}
-> JSON-lines: {"type":"progress","fraction":0.42}
{"type":"done","installed":true}
{"type":"error","message":"..."}
cancel {"model": "small"}
-> {"ok": true}
select {"model": "small"}
-> {"ok": true, "selected": "small"}
set_language {"language": "pt"} | "auto"
-> {"ok": true, "language": "pt"}
delete {"model": "small"}
-> {"ok": true}
open_finder {"model": "small"}
-> {"ok": true}
set_models_dir {"dir": "/path"}
-> {"ok": true, "models_dir": "/path"}
inspect {"path": "/path/to/project.fcpxml"}
-> {"ok": true, "path": "...", "name": "...", "fcpxml_version": "1.13",
"timelines": [{name, duration_seconds, frame_rate, width, height,
clips, cuts, connected, markers}]}
or {"ok": false, "error": "..."}
transcribe {"path": "...", "model": "small", "language": "pt"|null,
"hf_token": "..."|null, "num_speakers": ""|null}
-> JSON-lines:
{"type":"progress","fraction":0.5,"stage":"Transcrevendo..."}
{"type":"result","transcripts":[{"media","language","words",
"duration","preview","saved",
"speakers"}]}
{"type":"error","message":"..."}
edit_by_transcript {"path": "...", "phrases": ["frase um", "frase dois"],
"mode": "remove"|"keep_only", "clip_name": "..."|null,
"padding": 0.0, "model": "small", "language": "pt"|null}
-> {"ok": true, "path": "..._transcript_edit.fcpxml", "message": "..."}
or {"ok": false, "error": "..."}
remove_filler_words {"path": "...", "fillers": ["um","uh"]|null,
"clip_name": "..."|null, "padding": 0.02,
"model": "small", "language": "pt"|null}
-> {"ok": true, "path": "..._defillered.fcpxml", "message": "..."}
or {"ok": false, "error": "..."}
transcript_markers {"path": "...", "clip_name": "..."|null,
"marker_type": "chapter", "max_label_length": 50,
"model": "small", "language": "pt"|null}
-> {"ok": true, "path": "..._transcript_markers.fcpxml", "message": "..."}
or {"ok": false, "error": "..."}
add_zoom {"path": "...", "clip_id": "...", "start": 10.0, "end": 16.0,
"scale": 1.3, "ease": 0.3, "position": "0 0"|null}
-> {"ok": true, "path": "..._zoom.fcpxml", "message": "..."}
or {"ok": false, "error": "..."}
generate_dynamic_subtitles {"path": "...", "clip_name": "..."|null,
"band_height": 0.22, "block_center_y": -167,
"font": "Helvetica Neue", "font_size": 128,
"active_color": "1 1 1 1", "inactive_color": "0.7 0.7 0.7 1",
"model": "small", "language": "pt"|null}
-> {"ok": true, "path": "..._dynamic_subtitles.fcpxml", "message": "..."}
or {"ok": false, "error": "..."}
rename_speakers {"path": "/to/media_transcript.json",
"speakers": {"SPEAKER_01": "Nome"}}
-> {"ok": true, "speakers": [...]}
set_diarization {"token": "hf_...", "num_speakers": ""}
-> {"ok": true, "diarization": bool, "diarization_message": "...",
"num_speakers": "..."}
Exit code 0 on success, 1 on error.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import sys
import threading
from pathlib import Path
from typing import Any
# code/ is the package root for fcpxml and server modules.
_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code")
if _CODE_DIR not in sys.path:
sys.path.insert(0, _CODE_DIR)
from fcpxml.diarize import ( # noqa: E402
assign_speakers,
build_speakers,
diarization_capability,
diarize,
)
from fcpxml.media_intel import media_src_to_path # noqa: E402
from fcpxml.model_manager import ( # noqa: E402
download_model,
get_models_dir,
is_model_downloaded,
list_installed_models,
load_catalog,
load_hf_token,
load_num_speakers,
load_selected_model,
load_transcript_language,
model_cache_dir,
save_hf_token,
save_models_dir,
save_num_speakers,
save_selected_model,
save_transcript_language,
)
from fcpxml.parser import parse_fcpxml # noqa: E402
from fcpxml.transcribe import transcribe # noqa: E402
from fcpxml.writer import FCPXMLModifier # noqa: E402
RECOMMENDED = ("large-v3", "distil-large-v3", "small", "base")
def _derived_output(path: str, suffix: str, args: dict) -> str:
"""Resolve a derived XML path, optionally inside the chosen output folder."""
output_dir = str(args.get("output_dir", "")).strip()
if output_dir:
directory = Path(output_dir).expanduser()
directory.mkdir(parents=True, exist_ok=True)
source = Path(path)
extension = ".fcpxmld" if source.is_dir() else source.suffix
return str(directory / f"{source.stem}{suffix}{extension}")
from server import generate_output_path
return generate_output_path(path, suffix)
# Download cancellation events, keyed by model name.
_CANCEL: dict[str, threading.Event] = {}
_LOCK = threading.Lock()
def _emit(obj: Any) -> None:
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _transcript_json_path(media_path: str, output_dir: str = "") -> Path:
"""Where the ``_transcript.json`` for ``media_path`` lives.
When ``output_dir`` (the user-selected project folder) is set, the
transcript is saved/read there — never next to the source media, which
may sit on a read-only volume or a Final Cut Library the user never
browses. Falls back to the media's own folder only when no project
folder has been chosen (legacy/MCP callers).
"""
p = Path(media_path)
if output_dir:
directory = Path(output_dir).expanduser()
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{p.stem}_transcript.json"
return p.with_name(p.stem + "_transcript.json")
def _save_json_atomic(path: Path, data: Any) -> None:
"""Write ``data`` to ``path`` atomically and validate the result on disk.
Mirrors the reference WHISPERX save path: write a ``.tmp``, ``os.replace``
into place, then confirm the file exists, is non-empty, and parses as JSON.
"""
tmp_path = str(path) + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
if not path.exists() or os.path.getsize(path) == 0:
raise RuntimeError("O arquivo salvo está vazio ou não foi encontrado.")
with open(path, encoding="utf-8") as fh:
json.load(fh)
# ── commands ────────────────────────────────────────────────────────────────
def cmd_catalog() -> None:
catalog = load_catalog()
installed = list_installed_models()
diar_ok, diar_msg = diarization_capability(load_hf_token())
_emit(
{
"models": catalog,
"installed": installed,
"selected": load_selected_model(),
"language": load_transcript_language(),
"models_dir": str(get_models_dir()),
"installed_count": len(installed),
"recommended": list(RECOMMENDED),
"diarization": diar_ok,
"diarization_message": diar_msg,
"hf_token_set": bool(load_hf_token()),
"num_speakers": load_num_speakers(),
}
)
def cmd_download(args: dict) -> int:
model = str(args.get("model", ""))
if model not in _model_names():
_emit({"type": "error", "message": f"Modelo desconhecido: {model}"})
return 1
ev = threading.Event()
with _LOCK:
_CANCEL[model] = ev
try:
download_model(model, progress_cb=lambda f: _emit({"type": "progress", "fraction": f}), cancel_event=ev)
installed = is_model_downloaded(model)
_emit({"type": "done", "installed": installed})
if installed:
save_selected_model(model)
return 0 if installed else 1
except Exception as exc:
_emit({"type": "error", "message": str(exc)})
return 1
finally:
with _LOCK:
_CANCEL.pop(model, None)
def cmd_cancel(args: dict) -> None:
model = str(args.get("model", ""))
ev = _CANCEL.get(model)
if ev is not None:
ev.set()
_emit({"ok": True})
def cmd_select(args: dict) -> None:
model = str(args.get("model", ""))
if not is_model_downloaded(model):
_emit({"ok": False, "error": "Modelo não está instalado."})
return
save_selected_model(model)
_emit({"ok": True, "selected": load_selected_model()})
def cmd_set_language(args: dict) -> int:
"""Persist the transcription language (the default for every transcription)."""
lang = str(args.get("language", "auto"))
try:
saved = save_transcript_language(lang)
except ValueError as exc:
_emit({"ok": False, "error": str(exc)})
return 1
_emit({"ok": True, "language": saved})
return 0
def cmd_delete(args: dict) -> None:
model = str(args.get("model", ""))
try:
shutil.rmtree(model_cache_dir(model), ignore_errors=True)
except Exception:
pass
_emit({"ok": True})
def cmd_open_finder(args: dict) -> None:
target = str(args.get("path") or model_cache_dir(str(args.get("model", ""))))
try:
subprocess.Popen(["open", target])
except OSError:
pass
_emit({"ok": True})
def cmd_remove_silences(args: dict) -> int:
"""Run the canonical server silence remover into a suffixed copy."""
path = str(args.get("path", ""))
if not path or not Path(path).exists():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
try:
from server import handle_remove_media_silence
output = _derived_output(path, "_silence_removed", args)
contents = asyncio.run(handle_remove_media_silence({**args, "filepath": path, "output_path": output}))
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
if not Path(output).exists():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
def cmd_edit_by_transcript(args: dict) -> int:
"""Cut (or keep only) spoken phrases, using each media's cached transcript."""
path = str(args.get("path", ""))
phrases = args.get("phrases") or []
if not path or not Path(path).exists():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
if not isinstance(phrases, list) or not [p for p in phrases if str(p).strip()]:
_emit({"ok": False, "error": "Informe ao menos uma frase para cortar."})
return 1
try:
from server import handle_edit_by_transcript
output = _derived_output(path, "_transcript_edit", args)
contents = asyncio.run(handle_edit_by_transcript({**args, "filepath": path, "output_path": output}))
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
if not Path(output).exists():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
def cmd_remove_filler_words(args: dict) -> int:
"""Cut filler words (um, uh, ...) out, using each media's cached transcript."""
path = str(args.get("path", ""))
if not path or not Path(path).exists():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
try:
from server import handle_remove_filler_words
output = _derived_output(path, "_defillered", args)
contents = asyncio.run(handle_remove_filler_words({**args, "filepath": path, "output_path": output}))
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
if not Path(output).exists():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
def cmd_transcript_markers(args: dict) -> int:
"""Add a marker per transcribed segment, using each media's cached transcript."""
path = str(args.get("path", ""))
if not path or not Path(path).exists():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
try:
from server import handle_transcript_markers
output = _derived_output(path, "_transcript_markers", args)
contents = asyncio.run(handle_transcript_markers({**args, "filepath": path, "output_path": output}))
message = "\n".join(getattr(content, "text", str(content)) for content in contents)
if not Path(output).exists():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
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():
_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():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
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():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
if not clip_id:
_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():
_emit({"ok": False, "error": message})
return 1
_emit({"ok": True, "path": output, "message": message})
return 0
except Exception as exc:
_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():
_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,
})
_emit({"ok": True, "clips": clips})
return 0
except Exception as exc:
_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:
_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,
})
_emit({"ok": True, "segments": segments})
return 0
except Exception as exc:
_emit({"ok": False, "error": str(exc)})
return 1
def cmd_set_models_dir(args: dict) -> int:
try:
d = save_models_dir(str(args.get("dir", "")))
_emit({"ok": True, "models_dir": d})
return 0
except ValueError as exc:
_emit({"ok": False, "error": str(exc)})
return 1
def cmd_inspect(args: dict) -> int:
"""Validate an FCPXML file and return a summary of its projects/timelines."""
path = str(args.get("path", ""))
if not path:
_emit({"ok": False, "error": "Nenhum arquivo informado."})
return 1
if not Path(path).exists():
_emit({"ok": False, "error": "Arquivo não encontrado."})
return 1
try:
proj = parse_fcpxml(path)
except Exception as exc:
_emit({"ok": False, "error": f"Erro ao ler o projeto: {exc}"})
return 1
timelines = []
for tl in proj.timelines:
timelines.append(
{
"name": tl.name,
"duration_seconds": round(tl.duration.seconds, 3),
"frame_rate": round(tl.frame_rate, 3),
"width": tl.width,
"height": tl.height,
"clips": tl.total_clips,
"cuts": tl.total_cuts,
"connected": len(tl.connected_clips),
"markers": len(tl.markers),
}
)
_emit(
{
"ok": True,
"path": path,
"name": proj.name,
"fcpxml_version": proj.fcpxml_version,
"timelines": timelines,
}
)
return 0
def cmd_transcribe(args: dict) -> int:
proj_path = str(args.get("path", ""))
output_dir = str(args.get("output_dir", "")).strip()
# Honra o modelo selecionado no programa quando nenhum é passado.
model = str(args.get("model", "") or load_selected_model() or "")
language = args.get("language")
if language is None:
language = load_transcript_language()
if language == "auto":
language = None
if not proj_path:
_emit({"type": "error", "message": "Nenhum projeto selecionado."})
return 1
if not output_dir:
_emit({"type": "error", "message": "Selecione a pasta do projeto antes de transcrever."})
return 1
if not model or not is_model_downloaded(model):
_emit(
{
"type": "error",
"message": "Nenhum modelo de transcrição instalado. Baixe e selecione um modelo na aba Modelos.",
}
)
return 1
token = str(args.get("hf_token") or load_hf_token() or "")
if args.get("num_speakers") is not None:
num_speakers = str(args.get("num_speakers"))
else:
num_speakers = load_num_speakers()
# Load project.
try:
proj = parse_fcpxml(proj_path)
except Exception as exc:
_emit({"type": "error", "message": f"Erro ao ler o projeto: {exc}"})
return 1
tl = proj.primary_timeline or (proj.timelines[0] if proj.timelines else None)
media_paths: list[str] = []
if tl is not None:
for clip in getattr(tl, "clips", []):
mp = media_src_to_path(clip.media_path or "")
if mp and Path(mp).is_file() and mp not in media_paths:
media_paths.append(mp)
if not media_paths:
_emit({"type": "error", "message": "Nenhum arquivo de mídia acessível encontrado."})
return 1
total = len(media_paths)
results: list[dict] = []
for i, mp in enumerate(media_paths, 1):
_emit({"type": "progress", "fraction": i / total, "stage": f"Transcrevendo {Path(mp).name} ({i}/{total})…"})
json_path = _transcript_json_path(mp, output_dir)
cached = _load_cached_transcript(json_path)
if cached is not None:
results.append(_result_row(mp, cached))
continue
data = transcribe(mp, model_size=model, language=language)
if data is None:
_emit({"type": "error", "message": f"Não foi possível transcrever: {Path(mp).name}"})
return 1
# Diarização opcional (necessita token HF): assina speaker por segmento/palavra.
if token:
tracks = diarize(mp, token, num_speakers)
segments, words = assign_speakers(
data.get("segments", []), data.get("words", []), tracks
)
data = {**data, "segments": segments, "words": words}
data["speakers"] = build_speakers(data.get("segments", []))
payload = {
"schema_version": "1.0",
"source": Path(mp).name,
"model": model,
**data,
}
try:
_save_json_atomic(json_path, payload)
except (OSError, RuntimeError, ValueError) as exc:
_emit({"type": "error", "message": f"Não foi possível salvar o JSON: {exc}"})
return 1
results.append(_result_row(mp, data))
_emit({"type": "result", "transcripts": results})
return 0
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():
_emit({"ok": False, "error": "Arquivo de projeto não encontrado."})
return 1
try:
modifier = FCPXMLModifier(path)
except Exception as exc:
_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:
_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:
_emit({"ok": False, "error": "Nenhuma transcrição encontrada. Transcreva o projeto primeiro."})
return 1
_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 _load_cached_transcript(json_path: Path) -> dict | None:
"""Return a valid cached transcript dict, or ``None`` if absent/unreadable."""
if not json_path.is_file():
return None
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if isinstance(data, dict) and isinstance(data.get("words"), list):
if "speakers" not in data:
data["speakers"] = build_speakers(data.get("segments", []))
return data
return None
def cmd_rename_speakers(args: dict) -> int:
"""Apply real names to speakers already saved in a transcript JSON."""
json_path = Path(str(args.get("path", "")))
names = args.get("speakers") or {}
if not json_path.is_file():
_emit({"type": "error", "message": "Transcrição não encontrada."})
return 1
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_emit({"type": "error", "message": f"Não foi possível ler o JSON: {exc}"})
return 1
mapping = {str(sid): str(name).strip() for sid, name in (names or {}).items()}
for sp in data.get("speakers", []):
sid = str(sp.get("id", ""))
if sid in mapping and mapping[sid]:
sp["name"] = mapping[sid]
try:
_save_json_atomic(json_path, data)
except (OSError, RuntimeError, ValueError) as exc:
_emit({"type": "error", "message": f"Não foi possível salvar: {exc}"})
return 1
_emit({"ok": True, "speakers": data.get("speakers", [])})
return 0
def cmd_set_diarization(args: dict) -> int:
"""Persist the HuggingFace token and expected speaker count for diarization."""
token = args.get("token")
num = args.get("num_speakers")
if token is not None:
save_hf_token(str(token))
if num is not None:
save_num_speakers(str(num))
ok, msg = diarization_capability(load_hf_token())
_emit({"ok": True, "diarization": ok, "diarization_message": msg, "num_speakers": load_num_speakers()})
return 0
def _result_row(mp: str, data: dict) -> dict:
words = data.get("words", [])
preview = (data.get("text", "") or "")[:160]
speakers = data.get("speakers") or []
return {
"media": Path(mp).name,
"language": data.get("language", "?"),
"words": len(words),
"duration": float(data.get("duration", 0.0)),
"preview": preview,
"saved": str(_transcript_json_path(mp)),
"speakers": [s.get("name", s.get("id", "")) for s in speakers],
}
def _model_names() -> list[str]:
return [m["internal_name"] for m in load_catalog()]
def main() -> int:
args = sys.argv[1:]
if not args:
print("usage: models_api.py <command> [json_args]", file=sys.stderr)
return 1
command = args[0]
try:
data: dict = json.loads(args[1]) if len(args) > 1 else {}
except json.JSONDecodeError:
print("invalid JSON args", file=sys.stderr)
return 1
handlers = {
"catalog": cmd_catalog,
"download": cmd_download,
"cancel": cmd_cancel,
"select": cmd_select,
"set_language": cmd_set_language,
"delete": cmd_delete,
"open_finder": cmd_open_finder,
"set_models_dir": cmd_set_models_dir,
"inspect": cmd_inspect,
"transcribe": cmd_transcribe,
"export_srt": cmd_export_srt,
"remove_silences": cmd_remove_silences,
"edit_by_transcript": cmd_edit_by_transcript,
"remove_filler_words": cmd_remove_filler_words,
"transcript_markers": cmd_transcript_markers,
"generate_dynamic_subtitles": cmd_generate_dynamic_subtitles,
"add_zoom": cmd_add_zoom,
"zoom_clips": cmd_zoom_clips,
"zoom_segments": cmd_zoom_segments,
"rename_speakers": cmd_rename_speakers,
"set_diarization": cmd_set_diarization,
}
handler = handlers.get(command)
if handler is None:
print(f"unknown command: {command}", file=sys.stderr)
return 1
try:
result = handler(data)
except TypeError:
result = handler()
return result or 0
if __name__ == "__main__":
sys.exit(main())
+771
View File
@@ -0,0 +1,771 @@
#!/usr/bin/env python3
"""Transcription model manager — modern macOS UI (Flet), Hex-inspired.
Two tabs:
- Modelos: manage local Whisper models (download, progress, cancel, delete,
select, open in Finder, configure storage folder).
- Transcrição: pick a model + language, import an FCPXML/.fcpxmld project
(e.g. dragged out of Final Cut Pro), and transcribe its media locally,
writing a _transcript.json next to each media file.
Backed by the pure-Python ``fcpxml`` package. Run:
uv run python admin/models_gui.py
"""
from __future__ import annotations
import json
import logging
import subprocess
import sys
import threading
from pathlib import Path
from typing import Optional
import flet as ft
# code/ is the package root for fcpxml modules.
_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code")
if _CODE_DIR not in sys.path:
sys.path.insert(0, _CODE_DIR)
from fcpxml.media_intel import media_src_to_path # noqa: E402
from fcpxml.model_manager import ( # noqa: E402
download_model,
get_models_dir,
is_model_downloaded,
list_installed_models,
load_catalog,
load_selected_model,
model_cache_dir,
save_models_dir,
save_selected_model,
)
from fcpxml.parser import parse_fcpxml # noqa: E402
from fcpxml.transcribe import transcribe # noqa: E402
logger = logging.getLogger(__name__)
# Recommended models (badge) — mirrors Hex's "Suggested" concept.
RECOMMENDED = ("large-v3", "distil-large-v3", "small", "base")
# Transcription language options.
LANGUAGES = {
"auto": "Detectar automaticamente",
"pt": "Português",
"en": "Inglês",
"es": "Espanhol",
"fr": "Francês",
"de": "Alemão",
"it": "Italiano",
"nl": "Holandês",
"ja": "Japonês",
"ko": "Coreano",
"zh": "Chinês",
}
# Light theme palette (macOS-like).
ACCENT = "#007AFF"
BG = "#F5F5F7"
CARD = "#FFFFFF"
BORDER = "#E5E5EA"
TEXT = "#1D1D1F"
SUB = "#6E6E73"
GREEN = "#34C759"
RED = "#FF3B30"
PURPLE = "#AF52DE"
def _stars(count: int) -> ft.Row:
return ft.Row(
controls=[
ft.Icon(
ft.Icons.STAR_ROUNDED if i < count else ft.Icons.STAR_OUTLINE_ROUNDED,
size=15,
color=ACCENT if i < count else BORDER,
)
for i in range(5)
],
spacing=1,
)
def _transcript_json_path(media_path: str) -> Path:
p = Path(media_path)
return p.with_name(p.stem + "_transcript.json")
class ModelManagerApp:
"""Flet page controller for the model manager window."""
def __init__(self, page: ft.Page) -> None:
self.page = page
self.selected = load_selected_model()
self.downloading: Optional[str] = None
self._cancel_events: dict[str, threading.Event] = {}
self._picker: Optional[ft.FilePicker] = None
# ── helpers ────────────────────────────────────────────────────────────
def _refresh(self) -> None:
self.selected = load_selected_model()
self._rebuild_models_tab()
self.page.update()
def _refresh_models_content(self) -> ft.Column:
return self._build_model_list()
def _setup_picker(self) -> None:
if self._picker is not None:
return
self._picker = ft.FilePicker()
self._picker.on_result = self._on_file_picked
self.page.overlay.append(self._picker)
self._pending_target: Optional[dict] = None
def _on_file_picked(self, e) -> None:
if self._pending_target == "project":
if not e.files:
return
path = e.files[0].path
self._project_path.value = path
self._project_status.value = Path(path).name
self._project_status.color = SUB
elif self._pending_target == "models_dir":
path = getattr(e, "path", None)
if not path:
return
try:
save_models_dir(path)
self._dir_field.value = path
self._dir_status.value = "Pasta de modelos atualizada ✓"
self._dir_status.color = GREEN
except ValueError as err:
self._dir_status.value = str(err)
self._dir_status.color = RED
self._pending_target = None
self.page.update()
# ── main body (tabs) ───────────────────────────────────────────────────
def _rebuild_body(self) -> None:
self._setup_picker()
self.page.controls.clear()
models_tab = ft.Tab(
label="Modelos",
icon=ft.Icons.DATASET_OUTLINED,
)
models_tab.content = self._build_models_tab()
transcribe_tab = ft.Tab(
label="Transcrição",
icon=ft.Icons.MIC_OUTLINED,
)
transcribe_tab.content = self._build_transcribe_tab()
self.page.controls.append(
ft.Tabs(
content=[models_tab, transcribe_tab],
length=2,
selected_index=0,
expand=True,
animation_duration=200,
)
)
# ── Modelos tab ────────────────────────────────────────────────────────
def _build_models_tab(self) -> ft.Column:
return ft.Column(
controls=[
self._build_header(),
self._build_settings_card(),
self._build_model_list(),
self._build_footer(),
],
spacing=14,
expand=True,
)
def _rebuild_models_tab(self) -> None:
pass # content rebuilt on demand; simplest via full _rebuild_body
def _build_header(self) -> ft.Container:
return ft.Container(
content=ft.Row(
controls=[
ft.Container(
content=ft.Icon(ft.Icons.GRAPHIC_EQ, size=26, color=ft.Colors.WHITE),
width=48,
height=48,
border_radius=12,
bgcolor=ACCENT,
alignment=ft.Alignment(0, 0),
),
ft.Column(
controls=[
ft.Text("Modelos de Transcrição", size=22, weight=ft.FontWeight.W_700, color=TEXT),
ft.Text(
"Escolha um modelo local para transcrever seus depoimentos. "
"Mais precisão = mais lento e mais espaço.",
size=13,
color=SUB,
),
],
spacing=3,
expand=True,
),
],
spacing=14,
),
padding=ft.Padding(4, 6, 4, 10),
)
def _build_footer(self) -> ft.Container:
return ft.Container(
content=ft.Row(
controls=[
ft.Icon(ft.Icons.LOCK_OUTLINE, size=14, color=SUB),
ft.Text(
"Os modelos rodam localmente nesta máquina. "
"Downloads ficam na pasta configurada acima.",
size=11,
color=SUB,
),
],
spacing=6,
),
padding=ft.Padding(4, 2, 4, 2),
)
def _build_settings_card(self) -> ft.Container:
self._dir_field = ft.TextField(
value=str(get_models_dir()),
label="Pasta de modelos",
hint_text="Definida via ícone ao lado",
expand=True,
text_size=13,
border_radius=10,
filled=True,
read_only=True,
)
self._dir_status = ft.Text(
f"{len(list_installed_models())} instalado(s) · "
f"selecionado: {self.selected or 'nenhum'}",
size=12,
color=SUB,
)
def _pick_dir(e) -> None:
self._pending_target = "models_dir"
self._picker.get_directory_path(
dialog_title="Selecionar pasta de modelos",
)
def _open_dir(e) -> None:
try:
subprocess.Popen(["open", str(get_models_dir())])
except OSError:
pass
return ft.Container(
content=ft.Column(
controls=[
ft.Row(
controls=[
ft.Icon(ft.Icons.FOLDER_OUTLINED, size=18, color=SUB),
ft.Text("Local de armazenamento", size=13, weight=ft.FontWeight.W_600, color=TEXT),
],
spacing=8,
),
ft.Row(
controls=[
self._dir_field,
ft.IconButton(
ft.Icons.FOLDER_OPEN,
tooltip="Selecionar pasta",
on_click=_pick_dir,
icon_color=SUB,
),
ft.IconButton(
ft.Icons.OPEN_IN_NEW,
tooltip="Abrir no Finder",
on_click=_open_dir,
icon_color=SUB,
),
],
spacing=4,
),
self._dir_status,
],
spacing=10,
),
padding=16,
border_radius=14,
bgcolor=CARD,
border=ft.Border.all(1, BORDER),
shadow=ft.BoxShadow(
blur_radius=8,
offset=ft.Offset(0, 2),
color=ft.Colors.with_opacity(0.06, ft.Colors.BLACK),
),
)
def _build_model_list(self) -> ft.Column:
installed = set(list_installed_models())
rows = []
for catalog in load_catalog():
name = catalog["internal_name"]
rows.append(self._build_model_card(catalog, name in installed))
return ft.Column(controls=rows, spacing=10, scroll=ft.ScrollMode.AUTO, expand=True)
def _build_model_card(self, catalog: dict, installed: bool) -> ft.Container:
name: str = catalog["internal_name"]
display: str = catalog["display_name"]
is_selected = name == self.selected
is_downloading = name == self.downloading
is_recommended = name in RECOMMENDED
badges = ft.Row(spacing=6)
if is_recommended and not installed:
badges.controls.append(self._badge("Recomendado", ACCENT))
if installed:
badges.controls.append(self._badge("Instalado", GREEN))
if is_selected:
badges.controls.append(self._badge("Em uso", PURPLE))
progress = ft.ProgressBar(value=0, visible=is_downloading, width=170, color=ACCENT)
pct = ft.Text("0%", size=12, color=SUB, visible=is_downloading)
cancel_btn = ft.TextButton("Cancelar", visible=is_downloading, style=ft.ButtonStyle(color=RED))
def _cancel(name: str) -> None:
ev = self._cancel_events.get(name)
if ev is not None:
ev.set()
cancel_btn.on_click = lambda e, n=name: _cancel(n)
def _make_download(name: str):
def handler(e) -> None:
ev = threading.Event()
self._cancel_events[name] = ev
self.downloading = name
progress.visible = True
pct.visible = True
cancel_btn.visible = True
self.page.update()
threading.Thread(
target=_run_download, args=(name, progress, pct, cancel_btn, ev), daemon=True
).start()
return handler
def _run_download(name, progress, pct, cancel_btn, cancel_event) -> None:
def on_progress(fraction: float) -> None:
try:
progress.value = fraction
pct.value = f"{int(fraction * 100)}%"
self.page.update()
except Exception:
pass
download_model(name, progress_cb=on_progress, cancel_event=cancel_event)
self._cancel_events.pop(name, None)
self.downloading = None
installed_now = is_model_downloaded(name)
if installed_now:
save_selected_model(name)
self.selected = load_selected_model()
try:
self._rebuild_body()
self.page.update()
except Exception:
pass
controls_row = ft.Row(spacing=6)
if is_downloading:
controls_row.controls.extend([progress, pct, cancel_btn])
elif installed:
if is_selected:
controls_row.controls.append(
ft.Container(
content=ft.Row(
controls=[
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINED, size=16, color=GREEN),
ft.Text("Em uso", size=13, weight=ft.FontWeight.W_600, color=GREEN),
],
spacing=4,
),
padding=ft.Padding(10, 6, 10, 6),
border_radius=8,
border=ft.Border.all(1, GREEN),
)
)
else:
controls_row.controls.append(
ft.FilledButton(
"Selecionar",
on_click=lambda e, n=name: self._select(n),
style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE),
)
)
controls_row.controls.append(
ft.IconButton(
ft.Icons.FOLDER_OPEN,
tooltip="Mostrar no Finder",
on_click=lambda e, n=name: self._open_model(n),
icon_color=SUB,
)
)
controls_row.controls.append(
ft.IconButton(
ft.Icons.DELETE_OUTLINE,
tooltip="Remover download",
on_click=lambda e, n=name: self._delete(n),
icon_color=RED,
)
)
else:
controls_row.controls.append(
ft.FilledButton(
"Download",
on_click=_make_download(name),
style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE),
)
)
card_border = (
ft.Border.all(1.4, ACCENT) if is_recommended and not installed else ft.Border.all(1, BORDER)
)
return ft.Container(
content=ft.Column(
controls=[
ft.Row(
controls=[
ft.Text(display, size=16, weight=ft.FontWeight.W_600, color=TEXT, expand=True),
badges,
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
ft.Row(
controls=[
ft.Icon(ft.Icons.PUBLIC_OUTLINED, size=13, color=SUB),
ft.Text(catalog.get("size", ""), size=12, color=SUB),
ft.Text("·", size=12, color=BORDER),
ft.Icon(ft.Icons.STORAGE_OUTLINED, size=13, color=SUB),
ft.Text(catalog.get("storage", ""), size=12, color=SUB),
],
spacing=6,
),
ft.Row(
controls=[
_stars(int(catalog.get("accuracy", 0))),
ft.Text("Precisão", size=11, color=SUB),
ft.Container(width=14),
_stars(int(catalog.get("speed", 0))),
ft.Text("Velocidade", size=11, color=SUB),
],
spacing=6,
),
ft.Row(
controls=[controls_row],
alignment=ft.MainAxisAlignment.END,
),
],
spacing=10,
),
padding=16,
border_radius=14,
bgcolor=ft.Colors.with_opacity(0.55, CARD) if installed else CARD,
border=card_border,
shadow=ft.BoxShadow(
blur_radius=8,
offset=ft.Offset(0, 2),
color=ft.Colors.with_opacity(0.05, ft.Colors.BLACK),
),
)
def _badge(self, text: str, color: str) -> ft.Container:
return ft.Container(
content=ft.Text(text, size=10, weight=ft.FontWeight.W_700, color=color),
padding=ft.Padding(8, 3, 8, 3),
border_radius=8,
bgcolor=ft.Colors.with_opacity(0.12, color),
)
def _select(self, name: str) -> None:
if is_model_downloaded(name):
save_selected_model(name)
self._rebuild_body()
self.page.update()
def _delete(self, name: str) -> None:
import shutil
try:
shutil.rmtree(model_cache_dir(name), ignore_errors=True)
except Exception:
pass
self._rebuild_body()
self.page.update()
def _open_model(self, name: str) -> None:
try:
subprocess.Popen(["open", str(model_cache_dir(name))])
except OSError:
pass
# ── Transcrição tab ────────────────────────────────────────────────────
def _build_transcribe_tab(self) -> ft.Column:
# Model selection: installed models first, then all.
installed = list_installed_models()
model_options = [
ft.dropdown.Option(m, text=m) for m in installed
]
for catalog in load_catalog():
m = catalog["internal_name"]
if m not in installed:
model_options.append(ft.dropdown.Option(m, text=m))
model_default = self.selected if self.selected in installed else (installed[0] if installed else None)
model_dd = ft.Dropdown(
label="Modelo",
options=model_options,
value=model_default,
expand=True,
text_size=13,
border_radius=10,
)
lang_dd = ft.Dropdown(
label="Idioma",
options=[ft.dropdown.Option(k, text=v) for k, v in LANGUAGES.items()],
value="auto",
expand=True,
text_size=13,
border_radius=10,
)
self._project_path = ft.TextField(
label="Projeto FCPXML",
hint_text="Arraste o arquivo .fcpxml / .fcpxmld ou selecione abaixo",
expand=True,
read_only=True,
text_size=13,
border_radius=10,
)
self._project_status = ft.Text("Nenhum projeto selecionado", size=12, color=SUB)
def _pick_project(e) -> None:
self._pending_target = "project"
self._picker.pick_files(
dialog_title="Selecionar projeto FCPXML",
allow_multiple=False,
allowed_extensions=["fcpxml", "fcpxmld", "xml"],
)
progress_bar = ft.ProgressBar(value=0, visible=False, color=ACCENT)
status = ft.Text("", size=12, color=SUB)
result_box = ft.Container(
content=ft.Column(
controls=[ft.Text("", size=13, color=TEXT)],
spacing=8,
),
visible=False,
padding=14,
border_radius=10,
bgcolor=CARD,
border=ft.Border.all(1, BORDER),
)
def _transcribe_run(e) -> None:
proj_path = self._project_path.value
if not proj_path:
status.value = "Selecione um projeto FCPXML primeiro."
status.color = RED
self.page.update()
return
model = model_dd.value or "base"
lang = lang_dd.value
if lang == "auto":
lang = None
status.value = f"Transcrevendo com {model}… isso pode levar alguns minutos."
status.color = SUB
progress_bar.visible = True
progress_bar.value = 0
result_box.visible = False
self.page.update()
threading.Thread(
target=_run, args=(proj_path, model, lang, progress_bar, status, result_box), daemon=True
).start()
def _run(proj_path, model, lang, progress_bar, status, result_box) -> None:
try:
proj = parse_fcpxml(proj_path)
except Exception as exc:
_set_status(f"Erro ao ler o projeto: {exc}", RED)
return
tl = proj.primary_timeline or (proj.timelines[0] if proj.timelines else None)
if tl is None or not getattr(tl, "clips", None):
_set_status("Nenhum clip de mídia encontrado no projeto.", RED)
return
media_paths = []
for clip in tl.clips:
mp = media_src_to_path(clip.media_path or "")
if mp and Path(mp).is_file():
if mp not in media_paths:
media_paths.append(mp)
if not media_paths:
_set_status("Nenhum arquivo de mídia acessível encontrado.", RED)
return
total = len(media_paths)
results = []
for i, mp in enumerate(media_paths, 1):
_set_progress(i / total)
json_path = _transcript_json_path(mp)
if json_path.is_file():
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("words"), list):
results.append((mp, data))
continue
except (OSError, ValueError):
pass
data = transcribe(mp, model_size=model, language=lang)
if data is None:
_set_status(f"Não foi possível transcrever: {Path(mp).name}", RED)
return
try:
json_path.write_text(
json.dumps({"source": Path(mp).name, **data}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
_set_status(f"Não foi possível salvar o JSON: {exc}", RED)
return
results.append((mp, data))
# Render summary.
lines = [f"Transcrição concluída — {len(results)} arquivo(s)."]
total_words = 0
for mp, data in results:
nw = len(data.get("words", []))
total_words += nw
lines.append(f"• {Path(mp).name} — {nw} palavras")
lines.append(f"\nTotal: {total_words} palavras.")
result_box.content.controls[0].value = "\n".join(lines)
result_box.visible = True
_set_progress(1.0)
_set_status("Concluído ✓. Transcrições salvas como _transcript.json ao lado de cada mídia.")
def _set_progress(v: float) -> None:
try:
progress_bar.value = v
self.page.update()
except Exception:
pass
def _set_status(msg: str, color: str) -> None:
try:
status.value = msg
status.color = color
progress_bar.visible = False
self.page.update()
except Exception:
pass
return ft.Column(
controls=[
self._build_transcribe_header(),
ft.Container(
content=ft.Column(
controls=[
ft.Text("Configuração", size=13, weight=ft.FontWeight.W_600, color=TEXT),
ft.Row(controls=[model_dd, lang_dd], spacing=10),
ft.Row(
controls=[
self._project_path,
ft.FilledButton(
"Procurar…",
on_click=_pick_project,
style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE),
),
],
spacing=8,
),
self._project_status,
progress_bar,
ft.FilledButton(
"Transcrever",
on_click=_transcribe_run,
style=ft.ButtonStyle(bgcolor=ACCENT, color=ft.Colors.WHITE),
),
status,
result_box,
],
spacing=12,
),
padding=18,
border_radius=14,
bgcolor=CARD,
border=ft.Border.all(1, BORDER),
shadow=ft.BoxShadow(
blur_radius=8,
offset=ft.Offset(0, 2),
color=ft.Colors.with_opacity(0.06, ft.Colors.BLACK),
),
),
],
spacing=14,
expand=True,
)
def _build_transcribe_header(self) -> ft.Container:
return ft.Container(
content=ft.Row(
controls=[
ft.Container(
content=ft.Icon(ft.Icons.MIC, size=26, color=ft.Colors.WHITE),
width=48,
height=48,
border_radius=12,
bgcolor=ACCENT,
alignment=ft.Alignment(0, 0),
),
ft.Column(
controls=[
ft.Text("Transcrição", size=22, weight=ft.FontWeight.W_700, color=TEXT),
ft.Text(
"Importe seu projeto do Final Cut Pro, escolha modelo e idioma, "
"e transcreva o depoimento localmente.",
size=13,
color=SUB,
),
],
spacing=3,
expand=True,
),
],
spacing=14,
),
padding=ft.Padding(4, 6, 4, 10),
)
def main(page: ft.Page) -> None:
page.title = "Modelos de Transcrição"
page.window.width = 660
page.window.height = 800
page.window.min_width = 480
page.window.min_height = 480
page.theme_mode = ft.ThemeMode.LIGHT
page.bgcolor = BG
page.padding = 20
page.spacing = 14
app = ModelManagerApp(page)
app._rebuild_body()
if __name__ == "__main__":
ft.app(main)
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
#
# run_app.command — Compila e executa o app G-ART localmente (macOS).
#
# Uso:
# ./admin/run_app.command # compila e abre o app
#
# Requer: Xcode Command Line Tools (swiftc/xcrun) instalados.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
APP_NAME="GArt"
echo "==> Finalizando instância existente do app ($APP_NAME)..."
osascript -e 'tell application "System Events" to set pids to (unix id of every process whose name is "'"$APP_NAME"'")' 2>/dev/null && \
osascript -e 'tell application "'"$APP_NAME"'" to quit' 2>/dev/null || true
pkill -f "$ROOT_DIR/code/MacApp/build/$APP_NAME.app" 2>/dev/null || true
sleep 1
echo "==> Compilando o app (GArt)..."
"$ROOT_DIR/code/MacApp/build_app.sh"
echo "==> Abrindo o app localmente..."
open "$ROOT_DIR/code/MacApp/build/GArt.app"
echo "==> App G-ART iniciado. Fechando o Terminal..."
# Fecha a janela do Terminal de forma destacada: o processo é desacoplado do
# shell da janela (nohup + disown) e o script encerra antes, para que o aviso
# "finalizar processos nesta janela" (bash/osascript) não apareça.
(
sleep 2
osascript -e 'tell application "Terminal" to close (every window whose name contains "run_app")' >/dev/null 2>&1
) &
disown || true
exit 0
+243
View File
@@ -0,0 +1,243 @@
"""Tests for admin/models_api.py — the SwiftUI JSON bridge commands.
Focused on the transcription-flow changes: atomic save, speaker renaming, and
the "use the selected model" default plus model-availability guard.
"""
import json
import admin.models_api as api
def _capture(monkeypatch):
captured: list[dict] = []
def _emit(obj):
captured.append(obj)
monkeypatch.setattr(api, "_emit", _emit)
return captured
def test_save_json_atomic(tmp_path):
p = tmp_path / "t.json"
api._save_json_atomic(p, {"a": [1, 2], "text": "olá"})
assert p.exists()
assert not (tmp_path / "t.json.tmp").exists()
assert json.loads(p.read_text(encoding="utf-8"))["text"] == "olá"
def test_rename_speakers(tmp_path, monkeypatch):
captured = _capture(monkeypatch)
p = tmp_path / "t.json"
p.write_text(
json.dumps(
{
"speakers": [
{"id": "SPEAKER_00", "name": "Speaker 1"},
{"id": "SPEAKER_01", "name": "Speaker 2"},
]
}
),
encoding="utf-8",
)
assert api.cmd_rename_speakers({"path": str(p), "speakers": {"SPEAKER_01": "Erika"}}) == 0
assert captured[0]["ok"] is True
saved = json.loads(p.read_text(encoding="utf-8"))
assert saved["speakers"][0]["name"] == "Speaker 1"
assert saved["speakers"][1]["name"] == "Erika"
def test_rename_speakers_missing_file(monkeypatch):
captured = _capture(monkeypatch)
assert api.cmd_rename_speakers({"path": "/nonexistent/x.json"}) == 1
assert captured[0]["type"] == "error"
def test_transcribe_requires_output_dir(monkeypatch):
captured = _capture(monkeypatch)
monkeypatch.setattr(api, "load_selected_model", lambda: "small")
monkeypatch.setattr(api, "is_model_downloaded", lambda m: True)
assert api.cmd_transcribe({"path": "/some/project.fcpxml"}) == 1
assert captured[0]["type"] == "error"
assert "pasta do projeto" in captured[0]["message"]
def test_transcribe_requires_installed_model(monkeypatch, tmp_path):
captured = _capture(monkeypatch)
monkeypatch.setattr(api, "load_selected_model", lambda: "")
monkeypatch.setattr(api, "is_model_downloaded", lambda m: False)
assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1
assert captured[0]["type"] == "error"
assert "instalado" in captured[0]["message"]
def test_transcribe_defaults_to_selected_model(monkeypatch, tmp_path):
captured = _capture(monkeypatch)
monkeypatch.setattr(api, "load_selected_model", lambda: "small")
monkeypatch.setattr(api, "is_model_downloaded", lambda m: m == "small")
class FakeTL:
clips = []
class FakeProject:
primary_timeline = None
timelines = [FakeTL()]
monkeypatch.setattr(api, "parse_fcpxml", lambda p: FakeProject())
# No media accessible -> reaches the media-path check (past model validation).
assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1
assert captured[0]["type"] == "error"
assert "mídia" in captured[0]["message"]
def test_set_language_persists(monkeypatch):
captured = _capture(monkeypatch)
assert api.cmd_set_language({"language": "pt"}) == 0
assert captured[0]["ok"] is True
assert captured[0]["language"] == "pt"
assert api.load_transcript_language() == "pt"
def test_set_language_rejects_unknown(monkeypatch):
captured = _capture(monkeypatch)
assert api.cmd_set_language({"language": "xx"}) == 1
assert captured[0]["ok"] is False
assert "language" in captured[0]["error"]
def test_transcribe_defaults_language_to_persisted(monkeypatch, tmp_path):
monkeypatch.setattr(api, "load_selected_model", lambda: "small")
monkeypatch.setattr(api, "is_model_downloaded", lambda m: m == "small")
monkeypatch.setattr(api, "load_transcript_language", lambda: "pt")
media = tmp_path / "clip.mov"
media.write_bytes(b"fake")
class FakeClip:
media_path = ""
class FakeTL:
clips = [FakeClip()]
class FakeProject:
primary_timeline = None
timelines = [FakeTL()]
monkeypatch.setattr(api, "parse_fcpxml", lambda p: FakeProject())
monkeypatch.setattr(api, "media_src_to_path", lambda mp: str(media))
called = {}
monkeypatch.setattr(
api, "transcribe", lambda mp, model_size, language, **kw: called.update(lang=language)
)
assert api.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path / "out")}) == 1
assert called["lang"] == "pt"
def test_srt_stamp_format():
assert api.srt_stamp(0.0) == "00:00:00,000"
assert api.srt_stamp(1.5) == "00:00:01,500"
assert api.srt_stamp(3661.234) == "01:01:01,234"
_FCPXML_SAMPLE = """<?xml version="1.0" encoding="UTF-8"?>
<fcpxml version="1.13">
<resources>
<asset id="r1" name="clip" uid="u1" start="0s" duration="100s"
hasVideo="1" format="f1" hasAudio="1">
<media-rep kind="original-media" src="file:///tmp/clip.mp4"/>
</asset>
<format id="f1" name="FFVideoFormat1080p25" frameDuration="1/25s" width="1920" height="1080"/>
</resources>
<library>
<event name="Event">
<project name="P">
<sequence format="f1">
<spine>
<asset-clip ref="r1" offset="0s" start="10s" duration="10s" name="clip"/>
<gap name="Espaço" offset="10s" duration="90s" start="10s"/>
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
"""
def test_cmd_export_srt_maps_to_edited_timeline(tmp_path, monkeypatch):
"""Captions must reflect the EDITED timeline, not the whole source file."""
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
# Transcript covers 0..100s; the clip only USES source 10..20s -> timeline 0..10s.
transcript = {
"words": [],
"segments": [
{"start": 5.0, "end": 6.0, "text": "antes do corte"},
{"start": 12.0, "end": 14.0, "text": "dentro do corte"},
{"start": 50.0, "end": 51.0, "text": "depois do corte"},
]
}
tj = api._transcript_json_path(media)
tj.parent.mkdir(parents=True, exist_ok=True)
api._save_json_atomic(tj, transcript)
monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media))
assert api.cmd_export_srt({"path": str(project)}) == 0
assert captured[0]["ok"] is True
srt = tmp_path / "clip_captions.srt"
assert srt.exists()
text = srt.read_text(encoding="utf-8")
# Only the segment inside the used source window (12s) survives.
assert "dentro do corte" in text
assert "antes do corte" not in text
assert "depois do corte" not in text
# Mapped to timeline 0..10s -> the 12s source segment lands at 2s.
assert "00:00:02,000 --> 00:00:04,000" in text
def test_cmd_export_srt_no_transcript(tmp_path, monkeypatch):
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media))
assert api.cmd_export_srt({"path": str(project)}) == 1
assert captured[0]["ok"] is False
def test_cmd_export_srt_clamps_past_project_duration(tmp_path, monkeypatch):
"""A segment ending after the last clip must be clamped to the project end.
Final Cut rejects an SRT whose final cue overruns the timeline
("subtitle extends beyond project duration").
"""
captured = _capture(monkeypatch)
project = tmp_path / "proj.fcpxml"
project.write_text(_FCPXML_SAMPLE, encoding="utf-8")
media = tmp_path / "clip.mp4"
media.write_bytes(b"fake")
# Clip uses source 10..20s -> timeline 0..10s. A segment 12..30s maps to
# timeline 2..20s, but the project only lasts 10s: must clamp end to 10s.
transcript = {
"words": [],
"segments": [
{"start": 12.0, "end": 30.0, "text": "longa fala"},
]
}
tj = api._transcript_json_path(media)
tj.parent.mkdir(parents=True, exist_ok=True)
api._save_json_atomic(tj, transcript)
monkeypatch.setattr(api, "media_src_to_path", lambda src: str(media))
assert api.cmd_export_srt({"path": str(project)}) == 0
assert captured[0]["ok"] is True
srt = tmp_path / "clip_captions.srt"
text = srt.read_text(encoding="utf-8")
# Timeline is 10s; the cue must not end past it.
assert "00:00:02,000 --> 00:00:10,000" in text
assert "00:00:20,000" not in text