Files
gart/code/MacApp/Sources/Models.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

275 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// A curated transcription model (mirrors `fcpxml/models.json`).
struct ModelInfo: Identifiable {
let displayName: String
let internalName: String
let language: String
let storage: String
let accuracy: Int
let speed: Int
var isDownloaded: Bool
var isSelected: Bool
var isRecommended: Bool
var id: String { internalName }
}
/// Full catalog snapshot returned by the `catalog` command.
struct Catalog {
var models: [ModelInfo]
var selected: String
var language: String
var modelsDir: String
var installedCount: Int
var diarization: Bool
var diarizationMessage: String
var hfTokenSet: Bool
var numSpeakers: String
init(json: [String: Any]) {
let recommended = Set((json["recommended"] as? [String] ?? []))
let installed = Set((json["installed"] as? [String] ?? []))
let selected = json["selected"] as? String ?? ""
let rawModels = json["models"] as? [[String: Any]] ?? []
var parsed: [ModelInfo] = []
for m in rawModels {
let name = m["internal_name"] as? String ?? ""
parsed.append(ModelInfo(
displayName: m["display_name"] as? String ?? name,
internalName: name,
language: m["language"] as? String ?? "",
storage: m["storage"] as? String ?? "",
accuracy: m["accuracy"] as? Int ?? 0,
speed: m["speed"] as? Int ?? 0,
isDownloaded: installed.contains(name),
isSelected: name == selected,
isRecommended: recommended.contains(name)
))
}
self.models = parsed
self.selected = selected
self.language = json["language"] as? String ?? "auto"
self.modelsDir = json["models_dir"] as? String ?? ""
self.installedCount = json["installed_count"] as? Int ?? 0
self.diarization = json["diarization"] as? Bool ?? false
self.diarizationMessage = json["diarization_message"] as? String ?? ""
self.hfTokenSet = json["hf_token_set"] as? Bool ?? false
self.numSpeakers = json["num_speakers"] as? String ?? ""
}
/// Models sorted: recommended first, then by download state, then name.
var orderedModels: [ModelInfo] {
models.sorted {
if $0.isDownloaded != $1.isDownloaded { return $0.isDownloaded }
if $0.isRecommended != $1.isRecommended { return $0.isRecommended }
return $0.displayName < $1.displayName
}
}
}
/// A single transcript result from the `transcribe` command.
struct TranscriptResult {
let media: String
let language: String
let words: Int
let duration: Double
let preview: String
let saved: String
let speakers: [String]
init(json: [String: Any]) {
media = json["media"] as? String ?? ""
language = json["language"] as? String ?? "?"
words = json["words"] as? Int ?? 0
duration = json["duration"] as? Double ?? 0
preview = json["preview"] as? String ?? ""
saved = json["saved"] as? String ?? ""
speakers = json["speakers"] as? [String] ?? []
}
}
struct ZoomClip: Identifiable {
let id: String
let index: Int
let name: String
let start: Double
let duration: Double
let media: String
let preview: String
let hasTranscript: Bool
init(json: [String: Any]) {
id = json["id"] as? String ?? ""
index = json["index"] as? Int ?? 0
name = json["name"] as? String ?? "Clipe"
start = json["start"] as? Double ?? 0
duration = json["duration"] as? Double ?? 0
media = json["media"] as? String ?? ""
preview = json["preview"] as? String ?? ""
hasTranscript = json["has_transcript"] as? Bool ?? false
}
var label: String {
let time = String(format: "%02d:%02d", Int(start) / 60, Int(start) % 60)
return "Clipe \(index + 1) · \(name) · \(time)"
}
}
/// One word inside a phrase, with the acoustics that justify an emphasis.
struct ReviewWord: Identifiable {
let id: Int
let text: String
let start: Double
let end: Double
let energy: Double
let emphasis: Double
init(id: Int, json: [String: Any]) {
self.id = id
text = json["text"] as? String ?? ""
start = json["start"] as? Double ?? 0
end = json["end"] as? Double ?? 0
energy = json["energy"] as? Double ?? 0
emphasis = json["emphasis"] as? Double ?? 0
}
}
/// A phrase in the review step — one spoken line plus the decision made about
/// it. Mirrors `fcpxml/phrase_review.py`; `emphasis` is 0–3 and everything
/// mutable here is what the editor is allowed to change.
struct ReviewPhrase: Identifiable {
let id: Int
let start: Double
let end: Double
var trimStart: Double
var trimEnd: Double
var text: String
let speaker: String
var active: Bool
var emphasis: Int
var track: String
let peakEmphasis: Double
let emotion: String
let emotionConfidence: Double
let takeBoundary: Bool
let gapBefore: Double
let reason: String
let words: [ReviewWord]
static let trackScript = "roteiro"
static let trackBackstage = "bastidor"
/// Delivery emotion as the analysis names it, in the user's language plus a
/// glyph — the label alone is too easy to skim past in a dense list.
static func emotionLabel(_ emotion: String) -> (String, String) {
switch emotion {
case "excited": return ("Empolgado", "flame")
case "tense": return ("Tenso", "bolt")
case "calm": return ("Calmo", "leaf")
case "reflective": return ("Reflexivo", "moon")
default: return ("Neutro", "circle")
}
}
init(json: [String: Any]) {
id = json["index"] as? Int ?? 0
start = json["start"] as? Double ?? 0
end = json["end"] as? Double ?? 0
trimStart = json["trim_start"] as? Double ?? (json["start"] as? Double ?? 0)
trimEnd = json["trim_end"] as? Double ?? (json["end"] as? Double ?? 0)
text = json["text"] as? String ?? ""
speaker = json["speaker"] as? String ?? ""
active = json["active"] as? Bool ?? true
emphasis = json["emphasis"] as? Int ?? 0
track = json["track"] as? String ?? ReviewPhrase.trackScript
peakEmphasis = json["peak_emphasis"] as? Double ?? 0
emotion = json["emotion"] as? String ?? "neutral"
emotionConfidence = json["emotion_confidence"] as? Double ?? 0
takeBoundary = json["take_boundary"] as? Bool ?? false
gapBefore = json["gap_before"] as? Double ?? 0
reason = json["reason"] as? String ?? ""
words = (json["words"] as? [[String: Any]] ?? [])
.enumerated().map { ReviewWord(id: $0.offset, json: $0.element) }
}
var asJSON: [String: Any] {
[
"index": id,
"start": start,
"end": end,
"trim_start": trimStart,
"trim_end": trimEnd,
"text": text,
"speaker": speaker,
"active": active,
"emphasis": emphasis,
"track": track,
"reason": reason,
]
}
var isBackstage: Bool { track == ReviewPhrase.trackBackstage }
var isTrimmed: Bool { trimStart > start + 0.001 || trimEnd < end - 0.001 }
var timecode: String {
String(format: "%02d:%02d", Int(start) / 60, Int(start) % 60)
}
/// The word boundaries a trim handle is allowed to land on.
func snap(_ time: Double, edge: TrimEdge) -> Double {
let boundaries = words.map { edge == .start ? $0.start : $0.end }.filter { $0 > 0 }
guard let nearest = boundaries.min(by: { abs($0 - time) < abs($1 - time) }) else {
return time
}
return nearest
}
}
enum TrimEdge { case start, end }
/// A punch-in the editor placed by hand over an arbitrary range, next to the
/// whole-phrase zoom that an emphasis level produces. It stores only *when* —
/// the scale and the ramp come from the Voice Analysis settings at render time.
struct ManualZoom: Identifiable {
let id = UUID()
var start: Double
var end: Double
/// Below this a punch-in has no room to ramp in and back out; the writer
/// rejects the window, so offering it would place nothing.
static let minimumDuration: Double = 0.4
init(start: Double, end: Double) {
self.start = start
self.end = end
}
init?(json: [String: Any]) {
guard let start = json["start"] as? Double, let end = json["end"] as? Double,
end - start >= ManualZoom.minimumDuration
else { return nil }
self.start = start
self.end = end
}
var asJSON: [String: Any] { ["start": start, "end": end] }
}
struct ZoomSegment: Identifiable {
let id: Int
let start: Double
let end: Double
let text: String
init(json: [String: Any]) {
id = json["id"] as? Int ?? 0
start = json["start"] as? Double ?? 0
end = json["end"] as? Double ?? 0
text = json["text"] as? String ?? ""
}
var label: String {
String(format: "%06.2f–%06.2f %@", start, end, text)
}
}