Files
gart/admin/models_gui.py
T

772 lines
28 KiB
Python

#!/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)