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>
430 lines
16 KiB
Swift
430 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|