Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado. - generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro, legenda dinâmica só nas frases de ênfase, e a comum é desativada (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali. - validate_subtitle_layout ignora títulos com enabled="0" — corrige falso positivo de colisão contra o que está desativado no lugar dele. - Corrige zoom/marcador sendo descartado quando a borda encosta exatamente no início de um corte. - Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia entre "ativa" na tela e o que já foi cortado no FCPXML. - Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json) antes da cadeia de remoção de silêncio/legendas — antes, desativar uma frase na etapa 5 não tinha efeito nenhum no vídeo final. - Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder aparece assim que termina, sem slide extra. - Palavra clicável na etapa 5 agora funciona como toggle (clique de novo desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte). - fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento fonético via whisperx e roteirização local via Ollama/Gemma. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
443 lines
17 KiB
Swift
443 lines
17 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?
|
|
|
|
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` —
|
|
/// UNLESS `fresh` is true, in which case that saved review is ignored and
|
|
/// `active`/`emphasis`/etc. come straight from this call's `decisionsJSON`.
|
|
/// Pass `fresh: true` when the decisions themselves changed since the
|
|
/// review was last built (the caller re-pasted/regenerated the AI's JSON
|
|
/// and re-ran `apply_voice_actions`) — otherwise the saved review from the
|
|
/// PREVIOUS decisions silently wins over the fresh cut it should reflect,
|
|
/// which is exactly the desync the wizard's "active" toggle showed against
|
|
/// the just-reapplied FCPXML.
|
|
func load(voiceTimelinePath: String, decisionsJSON: String,
|
|
outputFolder: String? = nil, mediaFolder: String? = nil, fresh: Bool = false) {
|
|
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 fresh { arguments["fresh"] = true }
|
|
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
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
/// Clicking the word that is ALREADY that edge toggles it back off —
|
|
/// the trim on that side resets to the phrase's own start/end — so the
|
|
/// same click that sets a boundary also clears it, instead of needing
|
|
/// the separate "Inteira" button for a one-sided undo.
|
|
func trimToWord(_ word: ReviewWord, edge: TrimEdge, in id: Int) {
|
|
guard let phrase = phrases.first(where: { $0.id == id }) else { return }
|
|
let epsilon = 0.001
|
|
switch edge {
|
|
case .start where abs(word.start - phrase.trimStart) < epsilon:
|
|
update(id) { $0.trimStart = $0.start }
|
|
case .end where abs(word.end - phrase.trimEnd) < epsilon:
|
|
update(id) { $0.trimEnd = $0.end }
|
|
default:
|
|
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) }
|
|
}
|
|
|
|
// MARK: - Tempo compactado (sem os vãos do que foi cortado)
|
|
|
|
/// Kept spans of source media, in order, each carrying the position it
|
|
/// lands at once every removed stretch between phrases is squeezed out.
|
|
/// The timeline draws and scrubs in this space so it reads like the cut
|
|
/// itself instead of the raw take with holes in it.
|
|
private var keptSegments: [(rawStart: Double, rawEnd: Double, compactStart: Double)] {
|
|
var offset = 0.0
|
|
var segments: [(Double, Double, Double)] = []
|
|
for phrase in phrases.sorted(by: { $0.start < $1.start }) where phrase.active {
|
|
guard phrase.trimEnd > phrase.trimStart else { continue }
|
|
segments.append((phrase.trimStart, phrase.trimEnd, offset))
|
|
offset += phrase.trimEnd - phrase.trimStart
|
|
}
|
|
return segments
|
|
}
|
|
|
|
/// Maps a raw source-media time to its position on the compacted timeline.
|
|
/// Time inside removed material collapses to the boundary of the nearest
|
|
/// kept segment, so cut stretches take up no space at all.
|
|
func compactTime(_ raw: Double) -> Double {
|
|
let segments = keptSegments
|
|
for segment in segments {
|
|
if raw < segment.rawStart { return segment.compactStart }
|
|
if raw <= segment.rawEnd { return segment.compactStart + (raw - segment.rawStart) }
|
|
}
|
|
guard let last = segments.last else { return 0 }
|
|
return raw >= last.rawEnd ? last.compactStart + (last.rawEnd - last.rawStart) : 0
|
|
}
|
|
|
|
/// The inverse of `compactTime`: where a click on the compacted timeline
|
|
/// lands in the raw source media, for seeking and scrubbing.
|
|
func rawTime(fromCompact compact: Double) -> Double {
|
|
let segments = keptSegments
|
|
for segment in segments {
|
|
let compactEnd = segment.compactStart + (segment.rawEnd - segment.rawStart)
|
|
if compact <= compactEnd {
|
|
return segment.rawStart + max(0, compact - segment.compactStart)
|
|
}
|
|
}
|
|
return segments.last?.rawEnd ?? 0
|
|
}
|
|
|
|
/// Persists the edited review plus the actions derived from it. Called when
|
|
/// the wizard advances — the render itself happens in the next step.
|
|
/// Persists the edited review and hands back BOTH paths it wrote:
|
|
/// `review_path` (the human-readable `_phrase_review.json`) and
|
|
/// `actions_path` (`_phrase_actions.json`, the cut/zoom list derived from
|
|
/// it — what `finalizeProcessing` needs to actually apply the review's
|
|
/// active/inactive decisions instead of just filing them away).
|
|
func save(completion: @escaping (_ reviewPath: String?, _ actionsPath: String?) -> Void) {
|
|
guard !voiceTimelinePath.isEmpty, !phrases.isEmpty else {
|
|
completion(nil, 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, nil)
|
|
_ = error
|
|
return
|
|
}
|
|
completion(result?["review_path"] as? String, result?["actions_path"] as? String)
|
|
}
|
|
}
|
|
}
|
|
}
|