feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
711c397dfe
commit
7b5aed79ee
@@ -31,15 +31,31 @@ struct CaptionsView: View {
|
||||
@AppStorage("capSampleAfter") private var sampleAfter = "sua legenda"
|
||||
@AppStorage("capShowGuides") private var showsGuides = true
|
||||
|
||||
private let fontChoices = [
|
||||
"Helvetica Neue", "Helvetica", "Arial", "Avenir Next",
|
||||
"Futura", "SF Pro Display", "Georgia", "Impact",
|
||||
]
|
||||
/// Todas as famílias de fonte instaladas no macOS (sistema + usuário), as
|
||||
/// usadas por padrão primeiro, para o seletor listar tudo sem hardcode.
|
||||
private static let installedFontFamilies: [String] = {
|
||||
var families = NSFontManager.shared.availableFontFamilies
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
let preferred = ["Helvetica Neue", "Playfair Display", "Georgia", "Didot"]
|
||||
for family in preferred.reversed() {
|
||||
if let idx = families.firstIndex(of: family) {
|
||||
families.remove(at: idx)
|
||||
families.insert(family, at: 0)
|
||||
}
|
||||
}
|
||||
return families
|
||||
}()
|
||||
|
||||
private let emphasisFontChoices = [
|
||||
"Playfair Display", "Georgia", "Didot", "Futura",
|
||||
"Avenir Next", "Times New Roman", "Helvetica Neue", "Impact",
|
||||
]
|
||||
/// Lista para um picker: todas as famílias instaladas e, se o valor salvo
|
||||
/// não estiver entre elas (ex.: fonte de outro Mac), ele entra no topo
|
||||
/// para o seletor continuar exibindo a escolha atual.
|
||||
private func fontChoices(for current: String) -> [String] {
|
||||
var list = Self.installedFontFamilies
|
||||
if !list.contains(current) {
|
||||
list.insert(current, at: 0)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private let emphasisFaceChoices = [
|
||||
"Medium Italic", "Italic", "Bold Italic", "Bold", "Regular", "Light Italic",
|
||||
@@ -211,7 +227,7 @@ struct CaptionsView: View {
|
||||
private var bodySection: some View {
|
||||
Section("Linhas de apoio") {
|
||||
Picker("Fonte", selection: bound(\.font)) {
|
||||
ForEach(fontChoices, id: \.self) { Text($0).tag($0) }
|
||||
ForEach(fontChoices(for: config.font), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
slider(
|
||||
"Tamanho",
|
||||
@@ -226,7 +242,7 @@ struct CaptionsView: View {
|
||||
private var emphasisSection: some View {
|
||||
Section("Palavra de ênfase") {
|
||||
Picker("Fonte", selection: bound(\.emphasisFont)) {
|
||||
ForEach(emphasisFontChoices, id: \.self) { Text($0).tag($0) }
|
||||
ForEach(fontChoices(for: config.emphasisFont), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Picker("Estilo", selection: bound(\.emphasisFace)) {
|
||||
ForEach(emphasisFaceChoices, id: \.self) { Text($0).tag($0) }
|
||||
@@ -244,7 +260,7 @@ struct CaptionsView: View {
|
||||
private var plainSubtitleSection: some View {
|
||||
Section("Legenda comum") {
|
||||
Picker("Fonte", selection: plainBound(\.font)) {
|
||||
ForEach(fontChoices, id: \.self) { Text($0).tag($0) }
|
||||
ForEach(fontChoices(for: plainConfig.font), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
slider(
|
||||
"Tamanho",
|
||||
|
||||
@@ -28,30 +28,6 @@ final class PhraseReviewModel: ObservableObject {
|
||||
/// 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 = ""
|
||||
@@ -76,25 +52,17 @@ final class PhraseReviewModel: ObservableObject {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
outputFolder: String? = nil, mediaFolder: String? = nil, fresh: Bool = false) {
|
||||
self.voiceTimelinePath = voiceTimelinePath
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
@@ -102,6 +70,7 @@ final class PhraseReviewModel: ObservableObject {
|
||||
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
|
||||
@@ -160,7 +129,6 @@ final class PhraseReviewModel: ObservableObject {
|
||||
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
|
||||
@@ -173,22 +141,6 @@ final class PhraseReviewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -335,8 +287,21 @@ final class PhraseReviewModel: ObservableObject {
|
||||
|
||||
/// 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) {
|
||||
trim(id, edge: edge, to: edge == .start ? word.start : word.end)
|
||||
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
|
||||
@@ -400,11 +365,59 @@ final class PhraseReviewModel: ObservableObject {
|
||||
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.
|
||||
func save(completion: @escaping (String?) -> Void) {
|
||||
/// 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)
|
||||
completion(nil, nil)
|
||||
return
|
||||
}
|
||||
let arguments: [String: Any] = [
|
||||
@@ -418,11 +431,11 @@ final class PhraseReviewModel: ObservableObject {
|
||||
PythonBridge.call(command: "save_phrase_review", arguments: arguments) { result, error in
|
||||
Task { @MainActor in
|
||||
if let error {
|
||||
completion(nil)
|
||||
completion(nil, nil)
|
||||
_ = error
|
||||
return
|
||||
}
|
||||
completion(result?["review_path"] as? String)
|
||||
completion(result?["review_path"] as? String, result?["actions_path"] as? String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,32 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// The video surface, as a plain `AVPlayerLayer` in an `NSView`.
|
||||
/// The wizard's emphasis-review step.
|
||||
///
|
||||
/// AVKit's `VideoPlayer` would be the obvious choice and is a trap here: this
|
||||
/// app is built by invoking `swiftc` directly (see `MacApp/build_app.sh`), and
|
||||
/// `_AVKit_SwiftUI` aborts at launch instantiating its generic metadata under
|
||||
/// that build. A player layer needs only AVFoundation, which links cleanly —
|
||||
/// and the transport controls live in the timeline's own toolbar anyway, so
|
||||
/// nothing is lost by dropping AVKit's chrome.
|
||||
private struct PlayerSurface: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
/// When true the frame is filled and cropped instead of letterboxed — used
|
||||
/// to preview horizontal footage inside a vertical delivery frame.
|
||||
var fills: Bool
|
||||
|
||||
func makeNSView(context: Context) -> PlayerLayerView {
|
||||
let view = PlayerLayerView()
|
||||
view.player = player
|
||||
view.fills = fills
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: PlayerLayerView, context: Context) {
|
||||
if view.player !== player { view.player = player }
|
||||
view.fills = fills
|
||||
}
|
||||
}
|
||||
|
||||
final class PlayerLayerView: NSView {
|
||||
private let playerLayer = AVPlayerLayer()
|
||||
|
||||
var player: AVPlayer? {
|
||||
get { playerLayer.player }
|
||||
set { playerLayer.player = newValue }
|
||||
}
|
||||
|
||||
var fills: Bool = false {
|
||||
didSet { playerLayer.videoGravity = fills ? .resizeAspectFill : .resizeAspect }
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer = CALayer()
|
||||
layer?.backgroundColor = NSColor.black.cgColor
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer?.addSublayer(playerLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
layer = CALayer()
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer?.addSublayer(playerLayer)
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// The wizard's emphasis-review step, laid out like an editing room: preview on
|
||||
/// top, timeline across the bottom, and the script as an inspector down the
|
||||
/// right side.
|
||||
///
|
||||
/// The arrangement is the point. Every decision here is about a *sentence*, so
|
||||
/// the same phrase has to be legible in all three places at once — a block on
|
||||
/// the timeline, a line of text in the inspector, and a moment in the preview.
|
||||
/// Selecting in any one of them selects in the other two.
|
||||
/// Every decision here is about a *sentence* read from the original
|
||||
/// transcription, so the phrases are listed in full — each line shows the text
|
||||
/// as it will be said, a switch to keep or drop it from the cut, and the
|
||||
/// emphasis level. Selecting a line in the list also selects its block on the
|
||||
/// timeline below, and vice-versa.
|
||||
struct PhraseReviewView: View {
|
||||
@ObservedObject var model: PhraseReviewModel
|
||||
|
||||
var body: some View {
|
||||
VSplitView {
|
||||
HSplitView {
|
||||
previewPane
|
||||
.frame(minWidth: 320, idealWidth: 640)
|
||||
inspectorPane
|
||||
.frame(minWidth: 300, idealWidth: 360, maxWidth: 520)
|
||||
VStack(spacing: 0) {
|
||||
inspectorHeader
|
||||
Divider()
|
||||
List(selection: $model.selection) {
|
||||
ForEach($model.phrases) { $phrase in
|
||||
PhraseRow(phrase: $phrase, model: model)
|
||||
.tag(phrase.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.onChange(of: model.selection) { _, newValue in
|
||||
if let newValue { model.goTo(phraseID: newValue) }
|
||||
}
|
||||
Divider()
|
||||
summaryBar
|
||||
}
|
||||
.frame(minHeight: 240)
|
||||
|
||||
@@ -111,48 +57,6 @@ struct PhraseReviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
private var previewPane: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let player = model.player {
|
||||
// The footage here is usually vertical. Sizing the surface to
|
||||
// the take's own aspect keeps a 9:16 frame as tall as the pane
|
||||
// allows instead of shrinking it to fit a horizontal box.
|
||||
// Framed to what the project delivers, not to what the camera
|
||||
// recorded: these takes are shot horizontal and cut vertical,
|
||||
// so the raw frame would show material the audience never sees.
|
||||
ZStack {
|
||||
Color.black
|
||||
PlayerSurface(player: player, fills: model.isCropping)
|
||||
.aspectRatio(model.previewAspect, contentMode: .fit)
|
||||
.clipped()
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { framingBadge }
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black.opacity(0.85)
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "film.stack")
|
||||
.font(.system(size: 28)).foregroundStyle(.secondary)
|
||||
Text(model.source.isEmpty
|
||||
? "A análise de voz não registrou qual mídia foi usada."
|
||||
: "Não achei \(model.source) na pasta do projeto.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
Text("A revisão funciona igual sem o preview — ele só ajuda a conferir o corte.")
|
||||
.font(.caption).foregroundStyle(.tertiary)
|
||||
Button("Localizar a mídia…") { pickMedia() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
summaryBar
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryBar: some View {
|
||||
HStack(spacing: 16) {
|
||||
summaryItem("text.quote", "\(model.phrases.count) frases")
|
||||
@@ -181,28 +85,6 @@ struct PhraseReviewView: View {
|
||||
String(format: "%02d:%02d finais", Int(seconds) / 60, Int(seconds) % 60)
|
||||
}
|
||||
|
||||
/// Says which frame is on screen, and lets the editor flip to the raw take.
|
||||
/// Without it a centred crop looks like the footage itself, and someone
|
||||
/// would judge framing on an approximation without knowing it.
|
||||
@ViewBuilder
|
||||
private var framingBadge: some View {
|
||||
if model.projectAspect != nil, abs((model.projectAspect ?? 0) - model.videoAspect) > 0.01 {
|
||||
Button {
|
||||
model.matchProjectFraming.toggle()
|
||||
} label: {
|
||||
Label(model.matchProjectFraming ? "Enquadramento do projeto" : "Mídia original",
|
||||
systemImage: model.matchProjectFraming ? "crop" : "rectangle.expand.vertical")
|
||||
.font(.caption2)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.padding(6)
|
||||
.background(Capsule().fill(.black.opacity(0.45)))
|
||||
.foregroundStyle(.white)
|
||||
.padding(8)
|
||||
.help("A fonte é horizontal e o projeto é vertical — o preview mostra o corte central aproximado. O enquadramento real de cada clipe vem do Final Cut.")
|
||||
}
|
||||
}
|
||||
|
||||
private func pickMedia() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
@@ -373,14 +255,17 @@ private struct PhraseRow: View {
|
||||
FlowWords(words: phrase.words, phrase: phrase) { word, edge in
|
||||
model.trimToWord(word, edge: edge, in: phrase.id)
|
||||
}
|
||||
Text("Clique = começa aqui · ⌥clique = termina aqui")
|
||||
Text("Clique = começa/desfaz aqui · ⌥clique = termina/desfaz aqui · sublinhado = ênfase da palavra")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// The phrase's words as wrapping chips, dimmed where they fall outside the trim.
|
||||
/// The phrase's words as wrapping chips, dimmed where they fall outside the
|
||||
/// trim and underlined where the acoustics mark them as an emphasis peak —
|
||||
/// the same word-level signal `05-zoom.md` picks a punch-in's `start` from,
|
||||
/// made visible instead of buried in the JSON.
|
||||
private struct FlowWords: View {
|
||||
let words: [ReviewWord]
|
||||
let phrase: ReviewPhrase
|
||||
@@ -394,16 +279,31 @@ private struct FlowWords: View {
|
||||
alignment: .leading, spacing: 3) {
|
||||
ForEach(words) { word in
|
||||
let kept = word.start >= phrase.trimStart - 0.001 && word.end <= phrase.trimEnd + 0.001
|
||||
let level = EmphasisPalette.levelFromScore(word.emphasis)
|
||||
Text(word.text)
|
||||
.font(.caption2)
|
||||
.fontWeight(level >= 2 ? .semibold : .regular)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(kept ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.08))
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
if level >= 1 {
|
||||
Rectangle()
|
||||
.fill(EmphasisPalette.color(level))
|
||||
.frame(height: 2)
|
||||
.padding(.horizontal, 3)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(kept ? .primary : .secondary)
|
||||
.strikethrough(!kept)
|
||||
.help(
|
||||
level >= 1
|
||||
? "Ênfase \(EmphasisPalette.label(level).lowercased()) (\(Int(word.emphasis * 100))%)"
|
||||
: "Sem ênfase"
|
||||
)
|
||||
.onTapGesture {
|
||||
onTrim(word, NSEvent.modifierFlags.contains(.option) ? .end : .start)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,20 @@ enum EmphasisPalette {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same 0–3 tiers a phrase's `emphasis` uses, derived from a raw 0–1
|
||||
/// acoustic score — the thresholds `10-revisao-humana.md` documents for
|
||||
/// deriving a phrase's level from `peak_emphasis` when no explicit zoom
|
||||
/// was set, reused here per WORD so a word chip and a phrase row read as
|
||||
/// the same scale.
|
||||
static func levelFromScore(_ score: Double) -> Int {
|
||||
switch score {
|
||||
case ..<0.25: return 0
|
||||
case ..<0.45: return 1
|
||||
case ..<0.65: return 2
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
static func speakerColor(_ speaker: String, among speakers: [String]) -> Color {
|
||||
let palette: [Color] = [.teal, .purple, .green, .indigo, .brown, .cyan]
|
||||
guard let index = speakers.firstIndex(of: speaker) else { return .gray }
|
||||
@@ -47,7 +61,9 @@ struct TimelineTracksView: View {
|
||||
private let trackSpacing: CGFloat = 4
|
||||
|
||||
private var pps: CGFloat { CGFloat(model.pixelsPerSecond) }
|
||||
private var contentWidth: CGFloat { max(320, CGFloat(model.duration) * pps) }
|
||||
/// Width follows the *kept* duration, not the raw take's — the timeline
|
||||
/// draws the cut, so removed stretches take no horizontal space.
|
||||
private var contentWidth: CGFloat { max(320, CGFloat(model.keptDuration) * pps) }
|
||||
|
||||
/// Name, icon and height of each lane, in the order they stack. The gutter
|
||||
/// and the tracks are built from this one list so a label can never drift
|
||||
@@ -215,8 +231,8 @@ struct TimelineTracksView: View {
|
||||
Canvas { context, size in
|
||||
let step = tickStep()
|
||||
var time = 0.0
|
||||
while time <= model.duration {
|
||||
let position = x(time)
|
||||
while time <= model.keptDuration {
|
||||
let position = compactX(time)
|
||||
context.stroke(
|
||||
Path { $0.move(to: CGPoint(x: position, y: size.height - 6))
|
||||
$0.addLine(to: CGPoint(x: position, y: size.height)) },
|
||||
@@ -301,7 +317,8 @@ struct TimelineTracksView: View {
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let time = phrase.start + Double((value.location.x) / pps)
|
||||
let compactOrigin = model.compactTime(phrase.start)
|
||||
let time = model.rawTime(fromCompact: compactOrigin + Double(value.location.x / pps))
|
||||
model.trim(phrase.id, edge: edge, to: time)
|
||||
}
|
||||
)
|
||||
@@ -397,8 +414,8 @@ struct TimelineTracksView: View {
|
||||
private var scrubGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let from = Double(value.startLocation.x / pps)
|
||||
let to = Double(value.location.x / pps)
|
||||
let from = model.rawTime(fromCompact: Double(value.startLocation.x / pps))
|
||||
let to = model.rawTime(fromCompact: Double(value.location.x / pps))
|
||||
if abs(value.translation.width) > 3 {
|
||||
model.setRange(from: from, to: to)
|
||||
model.seek(to: min(from, to))
|
||||
@@ -486,10 +503,16 @@ struct TimelineTracksView: View {
|
||||
|
||||
// MARK: - Escala
|
||||
|
||||
private func x(_ time: Double) -> CGFloat { CGFloat(time) * pps }
|
||||
/// Pixel position of a raw source-media time, after collapsing whatever
|
||||
/// lies between it and the previous kept phrase.
|
||||
private func x(_ time: Double) -> CGFloat { compactX(model.compactTime(time)) }
|
||||
|
||||
/// Pixel position of a time already in the compacted (edited) timeline —
|
||||
/// used for the ruler and playhead, which think in that space directly.
|
||||
private func compactX(_ compactTime: Double) -> CGFloat { CGFloat(compactTime) * pps }
|
||||
|
||||
private func width(from: Double, to: Double) -> CGFloat {
|
||||
max(0, CGFloat(to - from) * pps)
|
||||
max(0, CGFloat(model.compactTime(to) - model.compactTime(from)) * pps)
|
||||
}
|
||||
|
||||
/// Ruler spacing that keeps labels ~80pt apart at any zoom.
|
||||
|
||||
@@ -9,7 +9,7 @@ import AppKit
|
||||
/// sair do app e escolher um arquivo na mão) vira copiar/colar assistido
|
||||
/// dentro da própria tela.
|
||||
enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
case projeto, transcricao, analise, exportarChat, revisar, finalizar, concluido
|
||||
case projeto, transcricao, analise, exportarChat, revisar, finalizar
|
||||
var id: Int { rawValue }
|
||||
|
||||
var titulo: String {
|
||||
@@ -20,7 +20,6 @@ enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
case .exportarChat: return "Decisões da IA"
|
||||
case .revisar: return "Revisar ênfases"
|
||||
case .finalizar: return "Processar"
|
||||
case .concluido: return "Concluído"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,10 +53,18 @@ struct WizardView: View {
|
||||
@State private var appliedPath: String?
|
||||
@State private var skippedVoiceEdit = false
|
||||
|
||||
// Passo 4 (alternativa) — gerar o roteiro direto por IA local (Ollama/Gemma 3)
|
||||
@State private var isGeneratingScript = false
|
||||
@State private var generateScriptModel = "gemma3:12b"
|
||||
@State private var generateScriptFeedback = ""
|
||||
@State private var ollamaModels: [String] = []
|
||||
|
||||
// Passo 5 — revisar ênfases
|
||||
@StateObject private var reviewModel = PhraseReviewModel()
|
||||
@State private var reviewLoadedFor: String?
|
||||
@State private var reviewLoadedForDecisions: String?
|
||||
@State private var phraseReviewPath: String?
|
||||
@State private var phraseActionsPath: String?
|
||||
|
||||
// Passo 6 — processamento final
|
||||
@State private var finalSilences = true
|
||||
@@ -178,7 +185,6 @@ struct WizardView: View {
|
||||
case .exportarChat: exportarChatStep
|
||||
case .revisar: revisarStep
|
||||
case .finalizar: finalizarStep
|
||||
case .concluido: concluidoStep
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +310,54 @@ struct WizardView: View {
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
if let voiceTimelinePath {
|
||||
// Alternativa automática: em vez de copiar/colar no chat, manda a
|
||||
// própria voice timeline (o arquivo inteiro) junto com o brief para
|
||||
// o modelo local (Ollama/Gemma 3) decidir a edição de uma vez —
|
||||
// cortes, zooms e textos numa única chamada, sem sair do app.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("OU gere o roteiro por IA local (Ollama/Gemma 3)").font(.callout.weight(.semibold))
|
||||
Text("O app envia a voice timeline completa (o arquivo) acompanhada do pedido para o modelo local decidir os cortes, zooms e textos de uma vez. Nada de copiar e colar.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
if ollamaModels.isEmpty {
|
||||
TextField("Modelo (ex.: gemma3:12b, llama3)", text: $generateScriptModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 260)
|
||||
} else {
|
||||
Picker("Modelo", selection: $generateScriptModel) {
|
||||
ForEach(ollamaModels, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(maxWidth: 260)
|
||||
TextField("Ou outro", text: $generateScriptModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Button {
|
||||
generateScript(voiceTimelinePath: voiceTimelinePath)
|
||||
} label: {
|
||||
if isGeneratingScript {
|
||||
HStack { ProgressView().controlSize(.small); Text("Gerando…") }
|
||||
} else {
|
||||
Label("Gerar roteiro por IA local", systemImage: "sparkles")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isGeneratingScript || voiceTimelinePath.isEmpty)
|
||||
}
|
||||
if !generateScriptFeedback.isEmpty {
|
||||
Label(generateScriptFeedback, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.green.opacity(0.07)))
|
||||
.onAppear { fetchOllamaModels() }
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
copyForChat(path: voiceTimelinePath)
|
||||
} label: {
|
||||
@@ -412,6 +466,11 @@ struct WizardView: View {
|
||||
.onAppear { loadReviewIfNeeded() }
|
||||
}
|
||||
|
||||
/// Processing and its result live on the SAME slide: the moment the last
|
||||
/// operation finishes (`finalPath` gets set), the open/reveal buttons
|
||||
/// appear right below the "Processar" button instead of gating behind a
|
||||
/// separate "Concluído" step the user has to click into — there was
|
||||
/// nothing on that slide worth a click of its own.
|
||||
private var finalizarStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("6. Finalize o corte").font(.title3.weight(.semibold))
|
||||
@@ -437,18 +496,11 @@ struct WizardView: View {
|
||||
.controlSize(.large)
|
||||
.disabled(isFinalizing || (!finalSilences && !finalFillers && !finalSubtitles && !finalDynamicSubtitles))
|
||||
|
||||
if !finalStatus.isEmpty && !isFinalizing {
|
||||
Text(finalStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var concluidoStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Concluído", systemImage: "checkmark.seal.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
if let finalPath {
|
||||
if let finalPath, !isFinalizing {
|
||||
Divider().padding(.vertical, 4)
|
||||
Label("Concluído", systemImage: "checkmark.seal.fill")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
Text(finalPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
HStack {
|
||||
Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: finalPath)) }
|
||||
@@ -456,22 +508,27 @@ struct WizardView: View {
|
||||
Button("Mostrar no Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: finalPath)])
|
||||
}
|
||||
Spacer()
|
||||
Button("Começar outro projeto") { resetWizard() }
|
||||
}
|
||||
} else if !finalStatus.isEmpty && !isFinalizing {
|
||||
Text(finalStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Divider().padding(.vertical, 8)
|
||||
Button("Começar outro projeto") { resetWizard() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navegação
|
||||
|
||||
/// `.finalizar` is the last step now — once it has a `finalPath`, the
|
||||
/// slide's own "Começar outro projeto" button is the way forward, so the
|
||||
/// footer's "Continuar" would be a second, redundant path to nowhere.
|
||||
private var navFooter: some View {
|
||||
HStack {
|
||||
if step != .projeto && step != .concluido {
|
||||
if step != .projeto {
|
||||
Button("Voltar") { goBack() }
|
||||
}
|
||||
Spacer()
|
||||
if step != .concluido {
|
||||
if step != .finalizar || finalPath == nil {
|
||||
Button(step == .finalizar ? "Concluir" : "Continuar") { goNext() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAdvance)
|
||||
@@ -490,18 +547,19 @@ struct WizardView: View {
|
||||
// exigir mais uma confirmação.
|
||||
case .revisar: return true
|
||||
case .finalizar: return finalPath != nil && !isFinalizing
|
||||
case .concluido: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func goNext() {
|
||||
guard let next = WizardStep(rawValue: step.rawValue + 1) else { return }
|
||||
// Sair da revisão grava o que foi decidido (e as ações derivadas dela)
|
||||
// ao lado da análise de voz. Nada é renderizado aqui: a etapa 6 é que
|
||||
// lê esse arquivo para dar zoom e legenda dinâmica só nas ênfases.
|
||||
// Sair da revisão grava o que foi decidido e as ações derivadas dela
|
||||
// (`_phrase_actions.json`) ao lado da análise de voz — é esse arquivo
|
||||
// que `finalizeProcessing` reaplica na etapa 6, para que desativar uma
|
||||
// frase aqui realmente a remova do vídeo final, e não só do registro.
|
||||
if step == .revisar {
|
||||
reviewModel.save { path in
|
||||
phraseReviewPath = path
|
||||
reviewModel.save { reviewPath, actionsPath in
|
||||
phraseReviewPath = reviewPath
|
||||
phraseActionsPath = actionsPath
|
||||
}
|
||||
}
|
||||
step = next
|
||||
@@ -521,7 +579,9 @@ struct WizardView: View {
|
||||
appliedPath = nil
|
||||
skippedVoiceEdit = false
|
||||
reviewLoadedFor = nil
|
||||
reviewLoadedForDecisions = nil
|
||||
phraseReviewPath = nil
|
||||
phraseActionsPath = nil
|
||||
finalStatus = ""
|
||||
finalPath = nil
|
||||
errorMessage = nil
|
||||
@@ -727,21 +787,33 @@ struct WizardView: View {
|
||||
copiedFeedback = "Copiado — cole (⌘V) numa conversa com o Claude."
|
||||
}
|
||||
|
||||
/// Monta a revisão uma vez por análise de voz. Voltar e avançar de novo não
|
||||
/// recarrega: isso jogaria fora as edições manuais em silêncio, que é
|
||||
/// exatamente o que esta tela existe para preservar.
|
||||
/// Monta a revisão uma vez por análise de voz. Voltar e avançar de novo com
|
||||
/// as MESMAS decisões não recarrega: isso jogaria fora as edições manuais
|
||||
/// em silêncio, que é exatamente o que esta tela existe para preservar.
|
||||
///
|
||||
/// Mas se o usuário voltou à etapa 4 e colou/gerou um JSON de decisões
|
||||
/// DIFERENTE do que gerou a revisão atual, isso é recarregado — e com
|
||||
/// `fresh: true`, para que o `active`/ênfase recém-derivado dessas
|
||||
/// decisões novas não seja imediatamente sobrescrito pela revisão salva
|
||||
/// da visita anterior (`merge_saved_decisions`, do lado Python). Sem isso,
|
||||
/// a tela ficava presa nas decisões antigas mesmo depois de reaplicar o
|
||||
/// corte — a dessincronia relatada entre "ativa aqui" e "já cortado no
|
||||
/// FCPXML".
|
||||
private func loadReviewIfNeeded() {
|
||||
guard let voiceTimelinePath, reviewLoadedFor != voiceTimelinePath else { return }
|
||||
guard let voiceTimelinePath else { return }
|
||||
let decisionsChanged = reviewLoadedForDecisions != nil && reviewLoadedForDecisions != decisionsText
|
||||
guard reviewLoadedFor != voiceTimelinePath || decisionsChanged else { return }
|
||||
reviewLoadedFor = voiceTimelinePath
|
||||
reviewLoadedForDecisions = decisionsText
|
||||
// A pasta do projeto e a do .fcpxml entram como onde procurar a mídia:
|
||||
// a análise de voz guarda só o nome do arquivo, não o caminho.
|
||||
reviewModel.load(
|
||||
voiceTimelinePath: voiceTimelinePath,
|
||||
decisionsJSON: decisionsText,
|
||||
outputFolder: outputFolder,
|
||||
mediaFolder: projectPath.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path }
|
||||
mediaFolder: projectPath.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path },
|
||||
fresh: decisionsChanged
|
||||
)
|
||||
if let projectPath { reviewModel.loadProjectFormat(projectPath: projectPath) }
|
||||
}
|
||||
|
||||
private func applyDecisions() {
|
||||
@@ -767,6 +839,55 @@ struct WizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Etapa 4 (alternativa): manda a voice timeline inteira para um modelo
|
||||
/// local (Ollama/Gemma 3) que dirige a edição de uma vez — sem copiar e
|
||||
/// colar. O motor devolve o roteiro legível + o JSON de ações e já aplica
|
||||
/// no FCPXML (non-destructive), igual ao fluxo manual "Aplicar decisões".
|
||||
private func fetchOllamaModels() {
|
||||
guard ollamaModels.isEmpty else { return }
|
||||
PythonBridge.call(command: "list_ollama_models", arguments: [:]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
if let models = result?["models"] as? [String], !models.isEmpty {
|
||||
ollamaModels = models
|
||||
if !models.contains(generateScriptModel) {
|
||||
generateScriptModel = models.first ?? generateScriptModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateScript(voiceTimelinePath: String) {
|
||||
guard let projectPath, let outputFolder else { return }
|
||||
isGeneratingScript = true
|
||||
generateScriptFeedback = ""
|
||||
errorMessage = nil
|
||||
PythonBridge.call(command: "generate_voice_script", arguments: [
|
||||
"voice_timeline": voiceTimelinePath,
|
||||
"filepath": projectPath,
|
||||
"output_dir": outputFolder,
|
||||
"model": generateScriptModel,
|
||||
"apply_to_fcpxml": true,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isGeneratingScript = false
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao gerar roteiro por IA local."
|
||||
return
|
||||
}
|
||||
// Traz as decisões de volta para a tela de revisão (etapa 5) e
|
||||
// marca como aplicadas, exatamente como o "Aplicar decisões".
|
||||
if let actionsPath = result?["actions_path"] as? String,
|
||||
let content = try? String(contentsOfFile: actionsPath, encoding: .utf8) {
|
||||
decisionsText = content
|
||||
}
|
||||
appliedPath = result?["applied_path"] as? String ?? projectPath
|
||||
skippedVoiceEdit = false
|
||||
generateScriptFeedback = "Roteiro gerado e aplicado — revise as ênfases na próxima etapa."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeProcessing() {
|
||||
guard let outputFolder else { return }
|
||||
let startPath = appliedPath ?? projectPath
|
||||
@@ -780,7 +901,45 @@ struct WizardView: View {
|
||||
isFinalizing = true
|
||||
errorMessage = nil
|
||||
finalStatus = "Iniciando…"
|
||||
finalizeStep(operations, index: 0, currentPath: startPath, outputFolder: outputFolder)
|
||||
applyReviewDecisions(startPath: startPath, outputFolder: outputFolder) { reviewedPath in
|
||||
finalizeStep(operations, index: 0, currentPath: reviewedPath, outputFolder: outputFolder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reapplies whatever the etapa-5 review decided (active/inactive
|
||||
/// phrases, manual zooms) on top of `startPath` before the finishing
|
||||
/// chain runs below. Without this, `appliedPath` stayed frozen at
|
||||
/// whatever `exportarChat`'s `apply_voice_actions` produced BEFORE the
|
||||
/// human review — so toggling a phrase off in the review only updated
|
||||
/// `_phrase_actions.json` on disk, never the video the wizard actually
|
||||
/// exports. A no-op (just hands `startPath` straight through) when the
|
||||
/// review step was never visited/saved this session.
|
||||
private func applyReviewDecisions(
|
||||
startPath: String, outputFolder: String, completion: @escaping (String) -> Void
|
||||
) {
|
||||
guard let phraseActionsPath,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: phraseActionsPath)),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let actions = parsed["actions"] else {
|
||||
completion(startPath)
|
||||
return
|
||||
}
|
||||
finalStatus = "Aplicando a revisão…"
|
||||
PythonBridge.call(command: "apply_voice_actions", arguments: [
|
||||
"path": startPath,
|
||||
"output_dir": outputFolder,
|
||||
"actions": actions,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
isFinalizing = false
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao aplicar a revisão."
|
||||
finalStatus = "Processamento interrompido."
|
||||
return
|
||||
}
|
||||
completion(result?["path"] as? String ?? startPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeStep(_ operations: [String], index: Int, currentPath: String, outputFolder: String) {
|
||||
|
||||
Reference in New Issue
Block a user