Files
gart/code/MacApp/Sources/PythonBridge.swift
T
João HenriqueandClaude Opus 5 1bebee4359 feat: etapa 5 do assistente — revisão de ênfases com timeline
Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da
IA chega carregada e o editor afina frase a frase o que é ênfase e o que
fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase
recebem zoom e legenda dinâmica; as demais ficam com legenda comum.

O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas
não muda e a etapa 6 segue intacta.

Backend (fcpxml/phrase_review.py):
- build_phrase_review funde o _voice_timeline.json com as actions da IA
- trim por frase que anda em fronteira de palavra; corte parcial da IA
  chega como trim em vez de ser arredondado fora
- phrase_review_to_actions volta a cuts/zooms + emphasis_spans
- merge_saved_decisions reaplica só as decisões salvas sobre uma revisão
  remontada da análise atual, para reprocessar a voz não ficar mascarado
- resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo

App (SwiftUI):
- layout de sala de edição: preview em cima, inspector à direita, timeline
  atravessando embaixo com seis trilhas rotuladas
- preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal,
  projeto vertical), com alternância para a mídia original
- reprodução pula os trechos removidos e para no fim do trecho
- zoom manual por trecho marcado, sem guardar escala: a forma vem das
  configurações de Análise de Voz no render
- emoção da fala exposta por frase

Correções encontradas no caminho:
- VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc;
  trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22)
- teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:29:27 -04:00

231 lines
9.3 KiB
Swift

import Foundation
/// Bridges the SwiftUI app to the Python engine via the `admin/models_api.py`
/// JSON bridge. The app launches the script as a subprocess and reads
/// newline-delimited JSON on stdout (JSON-lines), so long-running commands can
/// stream progress.
enum PythonBridge {
/// Resolve the Python interpreter + script path.
/// Prefers `uv run` so extras are available; falls back to `python3`.
static var scriptURL: URL {
// Search order: cwd + ancestors, the app's own bundle root, home, and
// some common mount points (the repo often lives under /Volumes/...).
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
var searchRoots: [URL] = []
var dir = cwd
for _ in 0..<8 { searchRoots.append(dir); dir.deleteLastPathComponent() }
if let bundle = Bundle.main.resourceURL {
searchRoots.append(bundle)
}
searchRoots.append(URL(fileURLWithPath: NSHomeDirectory()))
searchRoots.append(URL(fileURLWithPath: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/G-ART"))
for root in searchRoots {
let candidate = root.appendingPathComponent("admin/models_api.py")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
}
// Fall back to cwd.
return cwd.appendingPathComponent("admin/models_api.py")
}
/// Project root containing `admin/`.
static var projectRoot: URL {
scriptURL.deletingLastPathComponent().deletingLastPathComponent()
}
static var pythonCommand: [String] {
// Prefer `uv run python` (has extras); fall back to `python3`.
if let uv = findUV() {
return [uv, "run", "python", scriptURL.path]
}
return ["python3", scriptURL.path]
}
/// `admin/models_api.py` lives outside `code/`, but its dependencies
/// (`pyproject.toml`, `.venv`) live inside it. `uv run` picks the
/// environment from the process's cwd, not from the script path — so
/// running with cwd at the repo root made `uv` create/use a second,
/// empty `.venv` there, silently ignoring everything installed into
/// `code/.venv` (this cost a real debugging session: librosa/pyannote
/// installed successfully but the app kept reporting them missing).
/// Every `uv run` must share the same cwd as `uv sync` to see the same
/// environment.
static var workingDirectory: URL {
codeDirectory
}
/// Directory containing `pyproject.toml` — where `uv sync` must run from.
static var codeDirectory: URL {
projectRoot.appendingPathComponent("code")
}
/// Locate `uv` on PATH or in common install locations.
static func findUV() -> String? {
if let onPath = which("uv") { return onPath }
let candidates = [
"/usr/local/bin/uv",
"/opt/homebrew/bin/uv",
"/opt/local/bin/uv",
"\(NSHomeDirectory())/.local/bin/uv",
"\(NSHomeDirectory())/.cargo/bin/uv",
"/Library/Frameworks/Python.framework/Versions/3.14/bin/uv",
]
for c in candidates where FileManager.default.isExecutableFile(atPath: c) {
return c
}
return nil
}
private static func which(_ name: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["which", name]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do { try process.run() } catch { return nil }
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return (path?.isEmpty ?? true) ? nil : path
}
// MARK: - Streaming JSON-lines
/// Runs a command and collects every parsed JSON document on stdout.
/// `onProgress` is called for each document (used for download progress).
static func run(command: String, arguments: [String: Any] = [:],
onLine: @escaping ([String: Any]) -> Void,
completion: @escaping (Int, String?) -> Void) {
var cmd = pythonCommand
cmd.append(command)
if !arguments.isEmpty, let data = try? JSONSerialization.data(withJSONObject: arguments),
let str = String(data: data, encoding: .utf8) {
cmd.append(str)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = cmd
process.currentDirectoryURL = workingDirectory
let pipe = Pipe()
process.standardOutput = pipe
let errPipe = Pipe()
process.standardError = errPipe
var stderrBuffer = ""
var stdoutBuffer = ""
let outputLock = NSLock()
errPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if let s = String(data: data, encoding: .utf8) { stderrBuffer += s }
}
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let s = String(data: data, encoding: .utf8) else { return }
outputLock.lock()
stdoutBuffer += s
let parts = stdoutBuffer.split(separator: "\n", omittingEmptySubsequences: false)
stdoutBuffer = String(parts.last ?? "")
let lines = parts.dropLast()
outputLock.unlock()
for line in lines {
if let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] {
onLine(obj)
}
}
}
process.terminationHandler = { p in
pipe.fileHandleForReading.readabilityHandler = nil
errPipe.fileHandleForReading.readabilityHandler = nil
outputLock.lock()
let finalLine = stdoutBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
stdoutBuffer = ""
outputLock.unlock()
if !finalLine.isEmpty,
let obj = try? JSONSerialization.jsonObject(with: Data(finalLine.utf8)) as? [String: Any] {
onLine(obj)
}
let error = p.terminationStatus == 0 ? nil : (stderrBuffer.isEmpty ? "O comando \(command) falhou (código \(p.terminationStatus))." : stderrBuffer)
completion(Int(p.terminationStatus), error)
}
do {
try process.run()
} catch {
completion(1, error.localizedDescription)
}
}
// MARK: - uv sync (installing optional extras, e.g. acoustic analysis)
/// Runs `uv <arguments>` from `codeDirectory` (where `pyproject.toml`
/// lives), streaming each output line as plain text — used for
/// `sync --extra intelligence` so "Modelos" can install the librosa
/// extra without the user opening a terminal.
static func runUV(arguments: [String],
onLine: @escaping (String) -> Void,
completion: @escaping (Int, String?) -> Void) {
guard let uv = findUV() else {
completion(1, "uv não encontrado. Instale com: curl -LsSf https://astral.sh/uv/install.sh | sh")
return
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [uv] + arguments
process.currentDirectoryURL = codeDirectory
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
var buffer = ""
let lock = NSLock()
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let s = String(data: data, encoding: .utf8) else { return }
lock.lock()
buffer += s
let parts = buffer.split(separator: "\n", omittingEmptySubsequences: false)
buffer = String(parts.last ?? "")
let lines = parts.dropLast()
lock.unlock()
for line in lines where !line.isEmpty { onLine(String(line)) }
}
process.terminationHandler = { p in
pipe.fileHandleForReading.readabilityHandler = nil
lock.lock()
let last = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
buffer = ""
lock.unlock()
if !last.isEmpty { onLine(last) }
completion(Int(p.terminationStatus), p.terminationStatus == 0 ? nil : "uv sync terminou com erro (código \(p.terminationStatus)).")
}
do {
try process.run()
} catch {
completion(1, error.localizedDescription)
}
}
// MARK: - Convenience: single JSON result
/// Runs a command and delivers the first parsed JSON document as the result.
static func call(command: String, arguments: [String: Any] = [:],
completion: @escaping ([String: Any]?, String?) -> Void) {
var result: [String: Any]?
run(command: command, arguments: arguments, onLine: { obj in
if result == nil { result = obj }
}, completion: { code, err in
completion(result, err)
})
}
}