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:
João Henrique
2026-08-19 21:29:27 -04:00
co-authored by Claude Opus 5
parent e7748c2c58
commit 1bebee4359
31 changed files with 4622 additions and 83 deletions
+429
View File
@@ -0,0 +1,429 @@
import AVFoundation
import Combine
import Foundation
/// State behind the wizard's emphasis-review step.
///
/// Holds the phrases, the selection, and the player — together, because they
/// are one thing to the user: clicking a phrase moves the playhead, playing
/// moves the selection, and skipping a removed line only works if whoever owns
/// playback also knows which lines are removed.
///
/// The preview deliberately plays the *original* media and jumps over whatever
/// the edit removes, instead of rendering a cut first. Rendering to check a
/// toggle would put minutes between a decision and its result; jumping gives
/// the same reading instantly, and the real cut is generated later from the
/// exact same phrase list.
@MainActor
final class PhraseReviewModel: ObservableObject {
@Published var phrases: [ReviewPhrase] = []
@Published var selection: Int?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentTime: Double = 0
@Published var isPlaying = false
@Published var pixelsPerSecond: Double = 40
@Published var skipRemoved = true
@Published var zooms: [ManualZoom] = []
/// In/out the editor dragged on the timeline, in source seconds.
@Published var rangeStart: Double?
@Published var rangeEnd: Double?
/// Aspect ratio of the footage as recorded.
@Published var videoAspect: Double = 16.0 / 9.0
/// Aspect ratio the project delivers in, read from the .fcpxml. It is
/// routinely *not* the footage's: these takes are shot horizontal and
/// delivered vertical, so previewing the raw frame would show a crop the
/// audience never sees — and the emphasis decisions are about what lands on
/// screen. Nil until the project is known.
@Published var projectAspect: Double?
/// Whether the preview crops to the delivery frame. On by default whenever
/// the two aspects disagree.
@Published var matchProjectFraming = true
/// What the preview should actually draw.
var previewAspect: Double {
guard matchProjectFraming, let projectAspect else { return videoAspect }
return projectAspect
}
/// True when the delivery frame differs enough from the footage that the
/// preview is showing a crop rather than the whole take.
var isCropping: Bool {
guard matchProjectFraming, let projectAspect else { return false }
return abs(projectAspect - videoAspect) > 0.01
}
private(set) var source = ""
private(set) var sourcePath = ""
private(set) var duration: Double = 0
private(set) var speakers: [String] = []
private(set) var emotionAvailable = false
private(set) var player: AVPlayer?
private var voiceTimelinePath = ""
private var timeObserver: Any?
private var playbackLimit: Double?
let minPixelsPerSecond: Double = 8
let maxPixelsPerSecond: Double = 400
deinit {
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
}
}
// MARK: - Carregar
/// Builds the review from the voice timeline plus whatever the AI decided.
/// A review saved on a previous visit wins — see `cmd_build_phrase_review`.
/// Reads the delivery format from the project so the preview can frame the
/// take the way it will actually be seen.
func loadProjectFormat(projectPath: String) {
PythonBridge.call(command: "inspect", arguments: ["path": projectPath]) { [weak self] result, _ in
Task { @MainActor in
guard let self,
let timelines = result?["timelines"] as? [[String: Any]],
let first = timelines.first,
let width = first["width"] as? Int, let height = first["height"] as? Int,
width > 0, height > 0
else { return }
self.projectAspect = Double(width) / Double(height)
}
}
}
func load(voiceTimelinePath: String, decisionsJSON: String,
outputFolder: String? = nil, mediaFolder: String? = nil) {
self.voiceTimelinePath = voiceTimelinePath
isLoading = true
errorMessage = nil
var arguments: [String: Any] = ["voice_timeline": voiceTimelinePath]
if let outputFolder { arguments["output_dir"] = outputFolder }
if let mediaFolder { arguments["media_dir"] = mediaFolder }
if let data = decisionsJSON.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) {
arguments["actions"] = parsed
}
PythonBridge.call(command: "build_phrase_review", arguments: arguments) { [weak self] result, error in
Task { @MainActor in
guard let self else { return }
self.isLoading = false
if let error {
self.errorMessage = error
return
}
guard let result, result["ok"] as? Bool == true else {
self.errorMessage = result?["error"] as? String ?? "Não foi possível montar a revisão."
return
}
self.apply(result)
}
}
}
private func apply(_ result: [String: Any]) {
source = result["source"] as? String ?? ""
// The timeline JSON stores only the media's file name; the bridge
// resolves it to something openable (see phrase_review.resolve_source).
sourcePath = result["source_path"] as? String ?? ""
duration = result["duration"] as? Double ?? 0
speakers = result["speakers"] as? [String] ?? []
emotionAvailable = result["emotion_available"] as? Bool ?? false
phrases = (result["phrases"] as? [[String: Any]] ?? []).map { ReviewPhrase(json: $0) }
zooms = (result["zooms"] as? [[String: Any]] ?? []).compactMap { ManualZoom(json: $0) }
selection = phrases.first?.id
if let errors = result["errors"] as? [String], !errors.isEmpty {
errorMessage = "A IA mandou \(errors.count) decisão(ões) que não deu para ler — o resto foi aplicado."
}
preparePlayer()
}
/// Point the preview at a media file the user chose by hand — the way out
/// when the footage moved somewhere the automatic lookup can't reach.
func useMedia(at path: String) {
sourcePath = path
preparePlayer()
}
private func preparePlayer() {
guard !sourcePath.isEmpty, FileManager.default.fileExists(atPath: sourcePath) else {
player = nil
return
}
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
let asset = AVURLAsset(url: URL(fileURLWithPath: sourcePath))
let player = AVPlayer(playerItem: AVPlayerItem(asset: asset))
self.player = player
readAspect(from: asset)
// 60 Hz: the same observer drives the playhead *and* decides when to
// jump a removed stretch, so its period is the worst-case amount of cut
// material that can be heard before the skip lands. At 20 Hz that was an
// audible blip on every join.
let interval = CMTime(seconds: 1.0 / 60.0, preferredTimescale: 600)
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
Task { @MainActor in
self?.tick(time.seconds)
}
}
}
/// The displayed aspect ratio, honouring the rotation the camera recorded.
/// A phone take is stored 1920×1080 with a 90° transform: reading
/// `naturalSize` alone would call a vertical video horizontal.
private func readAspect(from asset: AVURLAsset) {
Task { [weak self] in
guard let track = try? await asset.loadTracks(withMediaType: .video).first,
let size = try? await track.load(.naturalSize),
let transform = try? await track.load(.preferredTransform)
else { return }
let displayed = size.applying(transform)
let width = abs(displayed.width), height = abs(displayed.height)
guard width > 0, height > 0 else { return }
await MainActor.run { self?.videoAspect = width / height }
}
}
// MARK: - Reprodução
private func tick(_ time: Double) {
currentTime = time
guard isPlaying else { return }
// Playing a single phrase or a marked range stops at its out point
// instead of running on into the rest of the take.
if let limit = playbackLimit, time >= limit {
pause()
seek(to: limit)
return
}
if skipRemoved, let jump = nextKeptTime(after: time), jump > time {
seek(to: jump)
}
if let phrase = phrase(at: time), selection != phrase.id {
selection = phrase.id
}
}
/// Where playback should resume when `time` lands on removed material.
/// Returns nil when the time is on material that survives.
func nextKeptTime(after time: Double) -> Double? {
for phrase in phrases where time >= phrase.start - 0.001 && time < phrase.end {
if !phrase.active { return phrase.end }
if time < phrase.trimStart { return phrase.trimStart }
if time >= phrase.trimEnd { return phrase.end }
return nil
}
return nil
}
func togglePlay() {
if isPlaying {
pause()
} else {
playbackLimit = nil
play()
}
}
private func play() {
guard let player else { return }
if skipRemoved, let jump = nextKeptTime(after: currentTime) { seek(to: jump) }
player.play()
isPlaying = true
}
func pause() {
player?.pause()
isPlaying = false
playbackLimit = nil
}
/// Play exactly one span and stop — how a cut is judged: in context, at
/// speed, without hunting for the out point by hand.
func playRange(from start: Double, to end: Double) {
guard end > start else { return }
seek(to: start)
playbackLimit = end
player?.play()
isPlaying = true
}
func playSelectedPhrase() {
guard let selection, let phrase = phrases.first(where: { $0.id == selection })
else { return }
playRange(from: phrase.active ? phrase.trimStart : phrase.start,
to: phrase.active ? phrase.trimEnd : phrase.end)
}
func seek(to time: Double) {
currentTime = max(0, time)
player?.seek(to: CMTime(seconds: max(0, time), preferredTimescale: 600),
toleranceBefore: .zero, toleranceAfter: .zero)
}
/// Move the playhead to a phrase and select it.
func goTo(phraseID: Int) {
guard let phrase = phrases.first(where: { $0.id == phraseID }) else { return }
selection = phraseID
seek(to: phrase.active ? phrase.trimStart : phrase.start)
}
func phrase(at time: Double) -> ReviewPhrase? {
phrases.first { time >= $0.start && time < $0.end }
}
func selectNeighbour(_ delta: Int) {
guard let selection, let index = phrases.firstIndex(where: { $0.id == selection }) else {
if let first = phrases.first { goTo(phraseID: first.id) }
return
}
let next = min(max(0, index + delta), phrases.count - 1)
goTo(phraseID: phrases[next].id)
}
// MARK: - Edições
private func update(_ id: Int, _ change: (inout ReviewPhrase) -> Void) {
guard let index = phrases.firstIndex(where: { $0.id == id }) else { return }
change(&phrases[index])
}
func setEmphasis(_ level: Int, for id: Int) {
update(id) { $0.emphasis = min(3, max(0, level)) }
}
func toggleActive(_ id: Int) {
update(id) { $0.active.toggle() }
}
func setTrack(_ track: String, for id: Int) {
update(id) { $0.track = track }
}
func setText(_ text: String, for id: Int) {
update(id) { $0.text = text }
}
/// Trim a phrase's head or tail, landing on a word boundary.
/// A trim that would swallow the whole line is refused — deactivating the
/// phrase is the way to remove it, and doing it by accident with a drag
/// would lose the emphasis decision along with the line.
func trim(_ id: Int, edge: TrimEdge, to time: Double) {
update(id) { phrase in
let snapped = phrase.snap(time, edge: edge)
switch edge {
case .start:
let value = min(max(phrase.start, snapped), phrase.trimEnd - 0.1)
if value < phrase.trimEnd { phrase.trimStart = value }
case .end:
let value = max(min(phrase.end, snapped), phrase.trimStart + 0.1)
if value > phrase.trimStart { phrase.trimEnd = value }
}
}
}
func resetTrim(_ id: Int) {
update(id) { $0.trimStart = $0.start; $0.trimEnd = $0.end }
}
/// Trim everything before/after a given word — the text-first way to cut,
/// since the editor reads the line and points at where it should begin.
func trimToWord(_ word: ReviewWord, edge: TrimEdge, in id: Int) {
trim(id, edge: edge, to: edge == .start ? word.start : word.end)
}
// MARK: - Trecho marcado e zooms
var hasRange: Bool {
guard let rangeStart, let rangeEnd else { return false }
return rangeEnd - rangeStart >= ManualZoom.minimumDuration
}
var rangeSpan: (start: Double, end: Double)? {
guard let rangeStart, let rangeEnd, rangeEnd > rangeStart else { return nil }
return (rangeStart, rangeEnd)
}
func setRange(from start: Double, to end: Double) {
rangeStart = min(start, end)
rangeEnd = max(start, end)
}
func clearRange() {
rangeStart = nil
rangeEnd = nil
}
/// Add a punch-in over the marked range. Scale and ramp are not stored:
/// they come from the "Análise de Voz" settings when the edit is rendered,
/// so changing the look there restyles every zoom at once.
func addZoomForRange() {
guard let span = rangeSpan, span.end - span.start >= ManualZoom.minimumDuration
else { return }
zooms.append(ManualZoom(start: span.start, end: span.end))
zooms.sort { $0.start < $1.start }
clearRange()
}
func addZoomForPhrase(_ id: Int) {
guard let phrase = phrases.first(where: { $0.id == id }) else { return }
zooms.append(ManualZoom(start: phrase.trimStart, end: phrase.trimEnd))
zooms.sort { $0.start < $1.start }
}
func removeZoom(_ id: UUID) {
zooms.removeAll { $0.id == id }
}
func zoom(at time: Double) -> ManualZoom? {
zooms.first { time >= $0.start && time <= $0.end }
}
func setEmphasisForAll(_ level: Int) {
for index in phrases.indices where phrases[index].active {
phrases[index].emphasis = level
}
}
// MARK: - Resumo e gravação
var emphasisCount: Int { phrases.filter { $0.active && $0.emphasis >= 1 }.count }
var removedCount: Int { phrases.filter { !$0.active }.count }
var keptDuration: Double {
phrases.filter { $0.active }.reduce(0) { $0 + ($1.trimEnd - $1.trimStart) }
}
/// Persists the edited review plus the actions derived from it. Called when
/// the wizard advances — the render itself happens in the next step.
func save(completion: @escaping (String?) -> Void) {
guard !voiceTimelinePath.isEmpty, !phrases.isEmpty else {
completion(nil)
return
}
let arguments: [String: Any] = [
"voice_timeline": voiceTimelinePath,
"source": source,
"duration": duration,
"speakers": speakers,
"phrases": phrases.map { $0.asJSON },
"zooms": zooms.map { $0.asJSON },
]
PythonBridge.call(command: "save_phrase_review", arguments: arguments) { result, error in
Task { @MainActor in
if let error {
completion(nil)
_ = error
return
}
completion(result?["review_path"] as? String)
}
}
}
}