chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+141
@@ -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."
|
||||
Executable
+59
@@ -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'."
|
||||
Executable
+142
@@ -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
|
||||
@@ -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).
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Executable
+38
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user