#!/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 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 from fcpxml.model_manager import ( 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 from fcpxml.transcribe import transcribe 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: str | None = None self._cancel_events: dict[str, threading.Event] = {} self._picker: ft.FilePicker | None = 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: dict | None = 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)