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>
231 lines
9.3 KiB
Swift
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)
|
|
})
|
|
}
|
|
}
|