A ponte JSON do app tinha 1.395 linhas e 37 comandos de oito assuntos diferentes num arquivo só. Agora models_api.py guarda apenas a referência dos comandos, a tabela de despacho e o main(); cada assunto virou um módulo em admin/api/ (models, project, editing, zoom, subtitles, transcription, voice, review), com a base comum em shared.py. Nada muda para o app: ele continua chamando admin/models_api.py por caminho, e os 37 comandos respondem igual — verificado rodando a ponte de verdade. Duas coisas que a divisão obrigou a arrumar: - A saída passa por `shared.emit` chamada pelo módulo, não pelo nome importado. Isso preserva a propriedade de que trocar `emit` num lugar só captura a saída de todos os comandos — que era acidental quando tudo morava no mesmo arquivo, e vira intencional agora. - `_CANCEL` e o lock eram globais compartilhados. O registro de downloads foi para models.py, junto de quem o usa, com lock próprio: o antigo protegia ao mesmo tempo o dicionário e a escrita em stdout, duas coisas sem relação. Também: admin/test_models_api.py estava fora de `testpaths` e nunca rodava. Movido para code/tests/ e ligado ao gate — 1441 → 1454 testes (ver Engine/docs/05_EXPERIENCIAS.md #24). Lint zerado, 1454 testes passando. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
771 lines
28 KiB
Python
771 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
|
|
|
|
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)
|