294 lines
11 KiB
Swift
294 lines
11 KiB
Swift
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
/// Project summary returned by the `inspect` command.
|
||
struct TimelineSummary {
|
||
let name: String
|
||
let duration: Double
|
||
let frameRate: Double
|
||
let width: Int
|
||
let height: Int
|
||
let clips: Int
|
||
let cuts: Int
|
||
let connected: Int
|
||
let markers: Int
|
||
|
||
init(json: [String: Any]) {
|
||
name = json["name"] as? String ?? "Untitled"
|
||
duration = json["duration_seconds"] as? Double ?? 0
|
||
frameRate = json["frame_rate"] as? Double ?? 0
|
||
width = json["width"] as? Int ?? 0
|
||
height = json["height"] as? Int ?? 0
|
||
clips = json["clips"] as? Int ?? 0
|
||
cuts = json["cuts"] as? Int ?? 0
|
||
connected = json["connected"] as? Int ?? 0
|
||
markers = json["markers"] as? Int ?? 0
|
||
}
|
||
|
||
var durationText: String {
|
||
let t = Int(duration)
|
||
return String(format: "%02d:%02d:%02d", t / 3600, (t % 3600) / 60, t % 60)
|
||
}
|
||
}
|
||
|
||
struct ProjectInfo {
|
||
let path: String
|
||
let name: String
|
||
let version: String
|
||
let timelines: [TimelineSummary]
|
||
|
||
init(json: [String: Any]) {
|
||
path = json["path"] as? String ?? ""
|
||
name = json["name"] as? String ?? "Untitled"
|
||
version = json["fcpxml_version"] as? String ?? "?"
|
||
timelines = (json["timelines"] as? [[String: Any]] ?? []).map(TimelineSummary.init)
|
||
}
|
||
}
|
||
|
||
/// Guia "Projeto" — primeira tela do app. Permite importar um arquivo FCPXML
|
||
/// clicando para selecionar ou arrastando e soltando do Final Cut Pro, e mostra
|
||
/// um resumo do projeto carregado.
|
||
struct ProjectView: View {
|
||
@State private var project: ProjectInfo?
|
||
@State private var isInspecting = false
|
||
@State private var errorMessage: String?
|
||
@State private var isTargeted = false
|
||
@State private var showTranscription = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
dropZone
|
||
HStack {
|
||
Button("Selecionar Arquivo…") { pickProjectFile() }
|
||
Spacer()
|
||
if let project {
|
||
Button {
|
||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: project.path)])
|
||
} label: {
|
||
Image(systemName: "folder")
|
||
}
|
||
.help("Mostrar no Finder")
|
||
}
|
||
}
|
||
if showTranscription, let project {
|
||
TranscriptionView(projectPath: project.path, embedded: true)
|
||
}
|
||
} header: {
|
||
Text("Projeto do Final Cut Pro")
|
||
} footer: {
|
||
Text("Selecione ou arraste o arquivo XML (.fcpxml) ou o bundle (.fcpxmld) exportado pelo Final Cut Pro.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if let errorMessage {
|
||
Section {
|
||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
|
||
if isInspecting {
|
||
Section {
|
||
ProgressView().controlSize(.small)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
}
|
||
|
||
if let project {
|
||
summarySection(project)
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.task { restoreLastProject() }
|
||
}
|
||
|
||
/// Reabre o último projeto salvo em ~/.fcp-mcp-server/config.json, para o
|
||
/// app voltar onde parou em vez de pedir o arquivo de novo a cada abertura.
|
||
/// É aqui que a restauração precisa morar: a TranscriptionView embutida só
|
||
/// existe depois que há um projeto carregado, então ela não consegue se
|
||
/// restaurar sozinha. Caminhos que sumiram do disco voltam vazios do
|
||
/// Python, e nesse caso a tela abre limpa como antes.
|
||
private func restoreLastProject() {
|
||
guard project == nil else { return }
|
||
PythonBridge.call(command: "project_config") { result, _ in
|
||
DispatchQueue.main.async {
|
||
guard project == nil,
|
||
let result, result["ok"] as? Bool == true,
|
||
let file = result["file"] as? String, !file.isEmpty else { return }
|
||
inspect(file)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Presents an NSOpenPanel configured to select a single file (not a
|
||
/// folder). The panel filters to the project extensions that matter.
|
||
private func pickProjectFile() {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseFiles = true
|
||
panel.canChooseDirectories = false
|
||
panel.allowsMultipleSelection = false
|
||
panel.prompt = "Selecionar"
|
||
panel.message = "Selecione o arquivo de projeto (.fcpxml) exportado pelo Final Cut Pro."
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
if isAccepted(url) {
|
||
inspect(url.path)
|
||
} else {
|
||
errorMessage = "Selecione um arquivo .fcpxml ou .xml do Final Cut Pro."
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Drop zone
|
||
|
||
private var dropZone: some View {
|
||
VStack(spacing: 10) {
|
||
Image(systemName: isTargeted ? "arrow.down.doc.fill" : "doc.badge.arrow.down")
|
||
.font(.system(size: 40))
|
||
.foregroundStyle(isTargeted ? Color.accentColor : .secondary)
|
||
Text(isTargeted ? "Solte para importar" : "Arraste o XML do Final Cut Pro aqui")
|
||
.font(.callout.weight(.medium))
|
||
Text("Ou clique em “Selecionar Arquivo…” abaixo.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 120)
|
||
.padding()
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(Color.accentColor.opacity(isTargeted ? 0.12 : 0.05))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.strokeBorder(
|
||
Color.accentColor.opacity(isTargeted ? 1 : 0.4),
|
||
style: StrokeStyle(lineWidth: 2, dash: [6])
|
||
)
|
||
)
|
||
.onDrop(
|
||
of: [.fileURL],
|
||
isTargeted: $isTargeted
|
||
) { providers in
|
||
handleDrop(providers)
|
||
}
|
||
}
|
||
|
||
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
||
guard let provider = providers.first else { return false }
|
||
|
||
// `loadObject(ofClass: URL.self)` is Foundation's own bridge for a
|
||
// dropped file URL and handles every representation Finder/FCP may
|
||
// hand back (NSURL via secure coding, a bookmark, a plain path).
|
||
// The item can ALSO be manually pulled as raw `Data` — but that only
|
||
// works if the bytes are exactly `URL.dataRepresentation`'s format
|
||
// (a UTF-8 absolute-string encoding), which a `.fcpxmld` *bundle*
|
||
// (a package macOS treats as a directory, not a plain file) does not
|
||
// always arrive as: the decode silently returns nil, so the drop
|
||
// looks like it does nothing. Try the robust path first.
|
||
if provider.canLoadObject(ofClass: URL.self) {
|
||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||
self.handleDroppedURL(url)
|
||
}
|
||
return true
|
||
}
|
||
|
||
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
|
||
var url: URL?
|
||
if let data = item as? Data {
|
||
url = URL(dataRepresentation: data, relativeTo: nil)
|
||
} else if let u = item as? URL {
|
||
url = u
|
||
}
|
||
self.handleDroppedURL(url)
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func handleDroppedURL(_ url: URL?) {
|
||
DispatchQueue.main.async {
|
||
if let url, isAccepted(url) {
|
||
inspect(url.path)
|
||
} else {
|
||
errorMessage = "Este arquivo não parece ser um projeto do Final Cut Pro."
|
||
}
|
||
}
|
||
}
|
||
|
||
private func isAccepted(_ url: URL) -> Bool {
|
||
let ext = url.pathExtension.lowercased()
|
||
return ext == "fcpxml" || ext == "fcpxmld" || ext == "xml"
|
||
}
|
||
|
||
// MARK: - Summary
|
||
|
||
@ViewBuilder
|
||
private func summarySection(_ project: ProjectInfo) -> some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "film.fill")
|
||
.font(.title2)
|
||
.foregroundStyle(.tint)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(project.name).font(.headline)
|
||
Text("FCPXML v\(project.version)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if project.timelines.isEmpty {
|
||
Text("Nenhuma timeline encontrada.")
|
||
.font(.callout).foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(project.timelines, id: \.name) { tl in
|
||
timelineRow(tl)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Resumo do Projeto")
|
||
}
|
||
}
|
||
|
||
private func timelineRow(_ tl: TimelineSummary) -> some View {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(tl.name).font(.body.weight(.medium))
|
||
Text("\(tl.width) × \(tl.height) · \(formattedFPS(tl.frameRate)) fps")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
VStack(alignment: .trailing, spacing: 4) {
|
||
Text(tl.durationText).font(.body.weight(.medium)).monospacedDigit()
|
||
Text("\(tl.clips) clipes · \(tl.cuts) cortes")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
|
||
private func formattedFPS(_ fps: Double) -> String {
|
||
if fps == fps.rounded() { return String(format: "%.0f", fps) }
|
||
return String(format: "%.2f", fps)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func inspect(_ path: String) {
|
||
isInspecting = true
|
||
errorMessage = nil
|
||
PythonBridge.call(command: "inspect", arguments: ["path": path]) { result, err in
|
||
DispatchQueue.main.async {
|
||
isInspecting = false
|
||
if let result, result["ok"] as? Bool == true {
|
||
project = ProjectInfo(json: result)
|
||
showTranscription = true
|
||
PythonBridge.call(command: "set_project_config",
|
||
arguments: ["file": path]) { _, _ in }
|
||
} else {
|
||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao ler o projeto."
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|