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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7748c2c58
commit
1bebee4359
@@ -44,12 +44,26 @@ enum PythonBridge {
|
||||
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 {
|
||||
projectRoot
|
||||
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.
|
||||
private static func findUV() -> String? {
|
||||
static func findUV() -> String? {
|
||||
if let onPath = which("uv") { return onPath }
|
||||
let candidates = [
|
||||
"/usr/local/bin/uv",
|
||||
@@ -148,6 +162,59 @@ enum PythonBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user