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:
co-authored by
Claude Opus 5
parent
e7748c2c58
commit
1bebee4359
@@ -19,6 +19,7 @@ import UniformTypeIdentifiers
|
||||
/// assunto.
|
||||
struct CaptionsView: View {
|
||||
@State private var config = CaptionStyleConfig.defaults
|
||||
@State private var plainConfig = PlainSubtitleConfig.defaults
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
|
||||
@@ -53,6 +54,13 @@ struct CaptionsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func plainBound<T>(_ keyPath: WritableKeyPath<PlainSubtitleConfig, T>) -> Binding<T> {
|
||||
Binding(
|
||||
get: { plainConfig[keyPath: keyPath] },
|
||||
set: { plainConfig[keyPath: keyPath] = $0; savePlain() }
|
||||
)
|
||||
}
|
||||
|
||||
private func colorBound(_ keyPath: WritableKeyPath<CaptionStyleConfig, String>) -> Binding<Color> {
|
||||
Binding(
|
||||
get: { Color(rgbaString: config[keyPath: keyPath]) },
|
||||
@@ -60,6 +68,13 @@ struct CaptionsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func plainColorBound(_ keyPath: WritableKeyPath<PlainSubtitleConfig, String>) -> Binding<Color> {
|
||||
Binding(
|
||||
get: { Color(rgbaString: plainConfig[keyPath: keyPath]) },
|
||||
set: { plainConfig[keyPath: keyPath] = $0.fcpxmlColorString; savePlain() }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
previewColumn
|
||||
@@ -152,6 +167,7 @@ struct CaptionsView: View {
|
||||
positionSection
|
||||
bodySection
|
||||
emphasisSection
|
||||
plainSubtitleSection
|
||||
calibrationSection
|
||||
}
|
||||
if let errorMessage {
|
||||
@@ -225,6 +241,35 @@ struct CaptionsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var plainSubtitleSection: some View {
|
||||
Section("Legenda comum") {
|
||||
Picker("Fonte", selection: plainBound(\.font)) {
|
||||
ForEach(fontChoices, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
slider(
|
||||
"Tamanho",
|
||||
value: plainBound(\.fontSize), in: 28...300, step: 1,
|
||||
readout: "\(Int(plainConfig.fontSize))pt",
|
||||
help: "Tamanho da legenda comum editável no Final Cut."
|
||||
)
|
||||
slider(
|
||||
"Máximo de palavras",
|
||||
value: plainBound(\.maxWords), in: 1...14, step: 1,
|
||||
readout: "\(Int(plainConfig.maxWords))",
|
||||
help: "Quantidade máxima de palavras por bloco de legenda."
|
||||
)
|
||||
slider(
|
||||
"Altura",
|
||||
value: plainBound(\.positionY), in: -1200...300, step: 1,
|
||||
readout: "\(Int(plainConfig.positionY))",
|
||||
help: "Posição vertical da legenda comum no quadro; valores mais negativos descem."
|
||||
)
|
||||
ColorPicker("Cor", selection: plainColorBound(\.fontColor), supportsOpacity: true)
|
||||
Toggle("Usar letra maiúscula", isOn: plainBound(\.uppercase))
|
||||
Toggle("Manter vírgula e ponto", isOn: plainBound(\.keepPunctuation))
|
||||
}
|
||||
}
|
||||
|
||||
private var calibrationSection: some View {
|
||||
Section {
|
||||
slider(
|
||||
@@ -305,8 +350,17 @@ struct CaptionsView: View {
|
||||
} else if let error {
|
||||
errorMessage = error
|
||||
}
|
||||
isLoading = false
|
||||
continuation.resume()
|
||||
PythonBridge.call(command: "plain_subtitle_config") { plainResult, plainError in
|
||||
DispatchQueue.main.async {
|
||||
if let plainResult {
|
||||
plainConfig = PlainSubtitleConfig(from: plainResult)
|
||||
} else if let plainError {
|
||||
errorMessage = plainError
|
||||
}
|
||||
isLoading = false
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +371,12 @@ struct CaptionsView: View {
|
||||
DispatchQueue.main.async { errorMessage = error }
|
||||
}
|
||||
}
|
||||
|
||||
private func savePlain() {
|
||||
PythonBridge.call(command: "set_plain_subtitle_config", arguments: plainConfig.arguments()) { _, error in
|
||||
DispatchQueue.main.async { errorMessage = error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// O estilo das legendas dinâmicas, no formato que a tela edita e o bridge
|
||||
@@ -402,6 +462,69 @@ struct CaptionStyleConfig {
|
||||
}
|
||||
}
|
||||
|
||||
struct PlainSubtitleConfig {
|
||||
var font: String
|
||||
var fontSize: Double
|
||||
var fontColor: String
|
||||
var maxWords: Double
|
||||
var positionY: Double
|
||||
var uppercase: Bool
|
||||
var keepPunctuation: Bool
|
||||
var textScale: Double
|
||||
|
||||
static let defaults = PlainSubtitleConfig(
|
||||
font: "Helvetica Neue",
|
||||
fontSize: 82,
|
||||
fontColor: "1 1 1 1",
|
||||
maxWords: 7,
|
||||
positionY: -820,
|
||||
uppercase: false,
|
||||
keepPunctuation: true,
|
||||
textScale: 2.0
|
||||
)
|
||||
|
||||
init(from json: [String: Any]) {
|
||||
let d = PlainSubtitleConfig.defaults
|
||||
self.init(
|
||||
font: json["font"] as? String ?? d.font,
|
||||
fontSize: (json["font_size"] as? NSNumber)?.doubleValue ?? d.fontSize,
|
||||
fontColor: json["font_color"] as? String ?? d.fontColor,
|
||||
maxWords: (json["max_words"] as? NSNumber)?.doubleValue ?? d.maxWords,
|
||||
positionY: (json["position_y"] as? NSNumber)?.doubleValue ?? d.positionY,
|
||||
uppercase: json["uppercase"] as? Bool ?? d.uppercase,
|
||||
keepPunctuation: json["keep_punctuation"] as? Bool ?? d.keepPunctuation,
|
||||
textScale: (json["text_scale"] as? NSNumber)?.doubleValue ?? d.textScale
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
font: String, fontSize: Double, fontColor: String, maxWords: Double,
|
||||
positionY: Double, uppercase: Bool, keepPunctuation: Bool, textScale: Double
|
||||
) {
|
||||
self.font = font
|
||||
self.fontSize = fontSize
|
||||
self.fontColor = fontColor
|
||||
self.maxWords = maxWords
|
||||
self.positionY = positionY
|
||||
self.uppercase = uppercase
|
||||
self.keepPunctuation = keepPunctuation
|
||||
self.textScale = textScale
|
||||
}
|
||||
|
||||
func arguments() -> [String: Any] {
|
||||
[
|
||||
"font": font,
|
||||
"font_size": Int(fontSize),
|
||||
"font_color": fontColor,
|
||||
"max_words": Int(maxWords),
|
||||
"position_y": positionY,
|
||||
"uppercase": uppercase,
|
||||
"keep_punctuation": keepPunctuation,
|
||||
"text_scale": textScale,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Parses an FCPXML "R G B A" space-separated 0-1 string into a Color.
|
||||
init(rgbaString: String) {
|
||||
|
||||
Reference in New Issue
Block a user