feat: initial commit - Jhonny Editor

- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
This commit is contained in:
João Henrique
2026-09-08 09:59:31 -04:00
commit b541f502ba
1507 changed files with 387650 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Integrações externas da engine."""
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>apple-speech-transcriber</string>
<key>CFBundleIdentifier</key>
<string>com.genialsistemas.apple-speech-transcriber</string>
<key>CFBundleName</key>
<string>Apple Speech Transcriber</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>O scanner precisa reconhecer a fala dos clipes da timeline para gerar a transcrição.</string>
</dict>
</plist>
@@ -0,0 +1,3 @@
from .provider_de_transcricao_apple import ProviderDeTranscricaoApple
__all__ = ["ProviderDeTranscricaoApple"]
@@ -0,0 +1,44 @@
import AppKit
import Foundation
import Speech
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
guard CommandLine.arguments.count >= 3 else { finalizar("uso: arquivo locale [somente_no_dispositivo]", 2); return }
let url = URL(fileURLWithPath: CommandLine.arguments[1])
let locale = Locale(identifier: CommandLine.arguments[2])
let somenteNoDispositivo = CommandLine.arguments.count > 3 && CommandLine.arguments[3] == "true"
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
finalizar("Apple Speech indisponível para o locale solicitado", 3); return
}
SFSpeechRecognizer.requestAuthorization { status in
guard status == .authorized else { self.finalizar("Permissão do Apple Speech não concedida", 4); return }
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = false
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition { request.requiresOnDeviceRecognition = true }
recognizer.recognitionTask(with: request) { result, error in
if let result = result, result.isFinal {
let segmentos = result.bestTranscription.segments.map {
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
}
let data = try! JSONSerialization.data(withJSONObject: ["segmentos": segmentos])
print(String(data: data, encoding: .utf8)!)
}
if error != nil || result?.isFinal == true { self.finalizar(nil, 0) }
}
}
}
private func finalizar(_ mensagem: String?, _ codigo: Int32) {
if let mensagem { fputs(mensagem + "\n", stderr) }
NSApplication.shared.terminate(nil)
exit(codigo)
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
@@ -0,0 +1,28 @@
import json
from pathlib import Path
import subprocess
from typing import Any
from ...scanner.modelos import SegmentoDeTranscricao
class ProviderDeTranscricaoApple:
"""Provider nativo macOS Speech; o áudio não passa por API Groq."""
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
somente_no_dispositivo: bool = False) -> None:
self.executavel = executavel
self.locale = locale
self.somente_no_dispositivo = somente_no_dispositivo
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
resultado = subprocess.run(
[self.executavel, str(arquivo), self.locale, str(self.somente_no_dispositivo).lower()],
check=True, capture_output=True, text=True,
)
dados = json.loads(resultado.stdout)
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]), s.get("confianca"))
for s in dados.get("segmentos", [])]
+3
View File
@@ -0,0 +1,3 @@
from .provider_de_transcricao_groq import ProviderDeTranscricaoGroq
__all__ = ["ProviderDeTranscricaoGroq"]
@@ -0,0 +1,66 @@
import json
import mimetypes
import ssl
import uuid
from pathlib import Path
from typing import Any, Callable
from urllib import request
from ...scanner.modelos import SegmentoDeTranscricao
class ProviderDeTranscricaoGroq:
"""Provider Groq compatível com o contrato de transcrição do scanner."""
endpoint = "https://api.groq.com/openai/v1/audio/transcriptions"
def __init__(self, api_key: str, modelo: str = "whisper-large-v3-turbo",
idioma: str = "pt", tempo_limite: float = 120.0,
requisicao: Callable[..., Any] | None = None) -> None:
if not api_key.strip():
raise ValueError("A chave da API Groq é obrigatória.")
self.api_key = api_key.strip()
self.modelo = modelo
self.idioma = idioma
self.tempo_limite = tempo_limite
self._requisicao = requisicao or request.urlopen
try:
import certifi
self._contexto_ssl = ssl.create_default_context(cafile=certifi.where())
except ImportError:
self._contexto_ssl = ssl.create_default_context()
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
arquivo = getattr(clipe, "arquivo", None)
if not arquivo:
raise ValueError("O clipe não possui arquivo de origem para transcrição.")
caminho = Path(arquivo)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {caminho}")
corpo, tipo = self._multipart(caminho)
req = request.Request(self.endpoint, data=corpo, method="POST", headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": tipo,
})
argumentos = {"timeout": self.tempo_limite}
if self._requisicao is request.urlopen:
argumentos["context"] = self._contexto_ssl
with self._requisicao(req, **argumentos) as resposta:
dados = json.loads(resposta.read().decode("utf-8"))
return [SegmentoDeTranscricao(float(item["start"]), float(item["end"]),
str(item.get("text", "")).strip(),
None)
for item in dados.get("segments", [])]
def _multipart(self, caminho: Path) -> tuple[bytes, str]:
limite = "----engine-groq-" + uuid.uuid4().hex
mime = mimetypes.guess_type(caminho.name)[0] or "application/octet-stream"
partes: list[bytes] = []
campos = {"model": self.modelo, "language": self.idioma,
"response_format": "verbose_json", "timestamp_granularities[]": "segment"}
for nome, valor in campos.items():
partes.append(f"--{limite}\r\nContent-Disposition: form-data; name=\"{nome}\"\r\n\r\n{valor}\r\n".encode())
partes.append(f"--{limite}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{caminho.name}\"\r\nContent-Type: {mime}\r\n\r\n".encode())
partes.append(caminho.read_bytes())
partes.append(f"\r\n--{limite}--\r\n".encode())
return b"".join(partes), f"multipart/form-data; boundary={limite}"
@@ -0,0 +1,3 @@
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
__all__ = ["ExtracaoDeAudio", "ParteDeAudio"]
@@ -0,0 +1,63 @@
from dataclasses import dataclass
from pathlib import Path
import subprocess
@dataclass(frozen=True)
class ParteDeAudio:
caminho: Path
inicio: float
fim: float
class ExtracaoDeAudio:
"""Prepara mídia local para providers de transcrição."""
def __init__(self, ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe",
duracao_da_parte: float = 600.0) -> None:
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
self.duracao_da_parte = duracao_da_parte
def duracao(self, arquivo: str | Path) -> float:
caminho = self._validar(arquivo)
resultado = subprocess.run(
[self.ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(caminho)],
check=True, capture_output=True, text=True,
)
return float(resultado.stdout.strip())
def extrair(self, arquivo: str | Path, destino: str | Path) -> list[ParteDeAudio]:
return self.extrair_intervalo(arquivo, destino, 0.0, None)
def extrair_intervalo(self, arquivo: str | Path, destino: str | Path,
inicio: float = 0.0, fim: float | None = None) -> list[ParteDeAudio]:
origem = self._validar(arquivo)
pasta = Path(destino)
pasta.mkdir(parents=True, exist_ok=True)
duracao_total = self.duracao(origem)
inicio = max(0.0, inicio)
fim = min(fim if fim is not None else duracao_total, duracao_total)
total = fim
partes: list[ParteDeAudio] = []
inicio = 0.0
indice = 0
while inicio < total:
fim_da_parte = min(inicio + self.duracao_da_parte, total)
saida = pasta / f"parte-{indice:04d}.wav"
subprocess.run([
self.ffmpeg, "-y", "-ss", str(inicio), "-i", str(origem),
"-t", str(fim_da_parte - inicio), "-vn", "-ac", "1", "-ar", "16000",
"-c:a", "pcm_s16le", str(saida),
], check=True, capture_output=True, text=True)
partes.append(ParteDeAudio(saida, inicio, fim_da_parte))
inicio = fim_da_parte
indice += 1
return partes
def _validar(self, arquivo: str | Path) -> Path:
caminho = Path(arquivo)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo de mídia não encontrado: {caminho}")
return caminho
@@ -0,0 +1,7 @@
from .cliente_mcp import ClienteMCP, ClienteMCPPorStdio
from .erros_mcp import ErroMCP, ErroDeConexaoMCP, ErroDeRespostaMCP, ErroDeFerramentaMCP
__all__ = ["ClienteMCP", "ClienteMCPPorStdio", "ErroMCP", "ErroDeConexaoMCP", "ErroDeRespostaMCP", "ErroDeFerramentaMCP"]
from .sessao_mcp import SessaoMCP
__all__ = ["SessaoMCP"]
@@ -0,0 +1,71 @@
from abc import ABC, abstractmethod
import json
import subprocess
from typing import Any
class ClienteMCP(ABC):
"""Define a comunicação técnica com o servidor MCP."""
@abstractmethod
def conectar(self) -> None: ...
@abstractmethod
def desconectar(self) -> None: ...
@abstractmethod
def esta_conectado(self) -> bool: ...
@abstractmethod
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: ...
class ClienteMCPPorStdio(ClienteMCP):
"""Cliente MCP para o servidor local do Premiere via stdio."""
def __init__(self, comando: list[str], tempo_limite: float = 30.0) -> None:
self.comando = comando
self.tempo_limite = tempo_limite
self._processo: subprocess.Popen[str] | None = None
self._proximo_id = 1
def conectar(self) -> None:
if self._processo is not None:
return
self._processo = subprocess.Popen(
self.comando,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
self._enviar("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "engine", "version": "0.1.0"}})
def desconectar(self) -> None:
if self._processo is not None:
self._processo.terminate()
self._processo = None
def esta_conectado(self) -> bool:
return self._processo is not None and self._processo.poll() is None
def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.esta_conectado():
raise RuntimeError("Cliente MCP não está conectado.")
resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}})
if resposta.get("error"):
raise RuntimeError(f"Erro MCP: {resposta['error']}")
return resposta.get("result", {})
def _enviar(self, metodo: str, parametros: dict[str, Any]) -> dict[str, Any]:
if self._processo is None or self._processo.stdin is None or self._processo.stdout is None:
raise RuntimeError("Processo MCP indisponível.")
identificador = self._proximo_id
self._proximo_id += 1
self._processo.stdin.write(json.dumps({"jsonrpc": "2.0", "id": identificador, "method": metodo, "params": parametros}) + "\n")
self._processo.stdin.flush()
linha = self._processo.stdout.readline()
if not linha:
raise RuntimeError("O MCP encerrou sem retornar resposta.")
return json.loads(linha)
@@ -0,0 +1,5 @@
from .conversor_de_timeline import ConversorDeTimeline
from .conversor_de_faixas import ConversorDeFaixas
from .conversor_de_clipes import ConversorDeClipes
__all__ = ["ConversorDeTimeline", "ConversorDeFaixas", "ConversorDeClipes"]
@@ -0,0 +1,26 @@
from typing import Any
from ....dominio import Clipe, IntervaloDeTempo
from ....scanner.modelos import ErroDeAnalise
class ConversorDeClipes:
def converter(self, dados: dict[str, Any], identificador_da_faixa: str, indice: int = 0) -> Clipe:
inicio = dados.get("start", dados.get("timeline_start"))
fim = dados.get("end", dados.get("timeline_end"))
identificador = dados.get("nodeId", dados.get("id"))
if identificador is None or inicio is None or fim is None:
raise ErroDeAnalise("resposta_incompleta", "Clipe sem identificador ou intervalo obrigatório.",
f"clipes[{indice}]", {"campos": list(dados)})
try:
intervalo = IntervaloDeTempo(float(inicio), float(fim))
origem_inicio = dados.get("inPoint", dados.get("source_start"))
origem_fim = dados.get("outPoint", dados.get("source_end"))
origem = (IntervaloDeTempo(float(origem_inicio), float(origem_fim))
if origem_inicio is not None and origem_fim is not None else None)
except (TypeError, ValueError) as exc:
raise ErroDeAnalise("intervalo_invalido", str(exc), f"clipes[{indice}]") from exc
return Clipe(str(identificador), str(dados.get("name", "")), intervalo, origem,
dados.get("sourceFile"), identificador_da_faixa,
bool(dados.get("offline", False)), dict(dados.get("metadata", {})))
@@ -0,0 +1,17 @@
from typing import Any
from ....dominio import Faixa
from .conversor_de_clipes import ConversorDeClipes
class ConversorDeFaixas:
def __init__(self, conversor_de_clipes: ConversorDeClipes | None = None) -> None:
self.conversor_de_clipes = conversor_de_clipes or ConversorDeClipes()
def converter(self, dados: dict[str, Any], tipo: str, indice: int) -> Faixa:
identificador = str(dados.get("id", f"{tipo}_{indice}"))
faixa = Faixa(identificador, str(dados.get("name", "")), tipo,
int(dados.get("index", indice)))
faixa.clipes = [self.conversor_de_clipes.converter(clipe, identificador, i)
for i, clipe in enumerate(dados.get("clips", []))]
return faixa
@@ -0,0 +1,30 @@
from typing import Any
from ....dominio import Timeline
from .conversor_de_faixas import ConversorDeFaixas
class ConversorDeTimeline:
"""Converte a resposta externa do MCP em objetos do domínio."""
def __init__(self, conversor_de_faixas: ConversorDeFaixas | None = None) -> None:
self.conversor_de_faixas = conversor_de_faixas or ConversorDeFaixas()
def converter(self, dados_brutos: dict[str, Any]) -> Timeline:
if not isinstance(dados_brutos, dict) or dados_brutos.get("id") is None:
from ....scanner.modelos import ErroDeAnalise
raise ErroDeAnalise("timeline_invalida", "A timeline precisa de um identificador.", "timeline.id")
timeline = Timeline(
identificador=str(dados_brutos["id"]),
nome=str(dados_brutos.get("name", "")),
duracao=dados_brutos.get("duration"),
taxa_de_quadros=dados_brutos.get("frameRate"),
largura=dados_brutos.get("frameSizeHorizontal"),
altura=dados_brutos.get("frameSizeVertical"),
)
faixas_de_video = dados_brutos.get("videoTracks", dados_brutos.get("video_tracks", []))
faixas_de_audio = dados_brutos.get("audioTracks", dados_brutos.get("audio_tracks", []))
for tipo, faixas in (("video", faixas_de_video), ("audio", faixas_de_audio)):
timeline.faixas.extend(self.conversor_de_faixas.converter(f, tipo, i)
for i, f in enumerate(faixas))
return timeline
@@ -0,0 +1,14 @@
class ErroMCP(Exception):
"""Erro geral da integração com o MCP."""
class ErroDeConexaoMCP(ErroMCP):
"""Erro ao conectar ao MCP."""
class ErroDeFerramentaMCP(ErroMCP):
"""Erro ao executar uma ferramenta MCP."""
class ErroDeRespostaMCP(ErroMCP):
"""Erro quando a resposta do MCP é inválida."""
@@ -0,0 +1,3 @@
from .acesso_ao_editor import AcessoAClipes, AcessoAoEditor, AcessoAFaixas, AcessoASequencia, AcessoATimeline
__all__ = ["AcessoAoEditor", "AcessoATimeline", "AcessoASequencia", "AcessoAFaixas", "AcessoAClipes"]
@@ -0,0 +1,47 @@
from typing import Any
from ..cliente_mcp import ClienteMCP
class AcessoATimeline:
"""Realiza consultas técnicas da timeline por meio do MCP."""
def __init__(self, cliente_mcp: ClienteMCP, nome_da_ferramenta: str = "get_active_sequence") -> None:
self.cliente_mcp = cliente_mcp
self.nome_da_ferramenta = nome_da_ferramenta
def obter_timeline_ativa(self) -> dict[str, Any]:
resposta = self.cliente_mcp.chamar(self.nome_da_ferramenta, {})
conteudo_estruturado = resposta.get("structuredContent", {})
return conteudo_estruturado.get("data", resposta)
class AcessoASequencia:
"""Consulta somente os dados da sequência ativa."""
def __init__(self, acesso_a_timeline: AcessoATimeline) -> None:
self.acesso_a_timeline = acesso_a_timeline
def obter_sequencia_ativa(self) -> dict[str, Any]:
return self.acesso_a_timeline.obter_timeline_ativa()
class AcessoAFaixas:
def obter_faixas(self, sequencia: dict[str, Any]) -> list[dict[str, Any]]:
return list(sequencia.get("videoTracks", sequencia.get("video_tracks", []))) + list(
sequencia.get("audioTracks", sequencia.get("audio_tracks", [])))
class AcessoAClipes:
def obter_clipes(self, faixa: dict[str, Any]) -> list[dict[str, Any]]:
return list(faixa.get("clips", []))
class AcessoAoEditor:
"""Expõe operações de leitura compreensíveis pelo domínio."""
def __init__(self, acesso_a_timeline: AcessoATimeline) -> None:
self.acesso_a_timeline = acesso_a_timeline
def obter_timeline_ativa(self) -> dict[str, Any]:
return self.acesso_a_timeline.obter_timeline_ativa()
@@ -0,0 +1,17 @@
from contextlib import AbstractContextManager
from .cliente_mcp import ClienteMCP
class SessaoMCP(AbstractContextManager["SessaoMCP"]):
"""Garante o ciclo de vida de uma conexão MCP durante uma leitura."""
def __init__(self, cliente: ClienteMCP) -> None:
self.cliente = cliente
def __enter__(self) -> "SessaoMCP":
self.cliente.conectar()
return self
def __exit__(self, tipo, valor, traceback) -> None:
self.cliente.desconectar()
@@ -0,0 +1,3 @@
from .provider_de_transcricao_local import ProviderDeTranscricaoLocal
__all__ = ["ProviderDeTranscricaoLocal"]
@@ -0,0 +1,21 @@
from pathlib import Path
from typing import Any
from ...scanner.modelos import SegmentoDeTranscricao
class ProviderDeTranscricaoLocal:
"""Provider local usando faster-whisper, sem transmitir mídia."""
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
idioma: str = "pt") -> None:
from faster_whisper import WhisperModel
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
self.idioma = idioma
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma, vad_filter=True)
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip()) for s in segmentos]