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." } } } } }