Files
gart/code/MacApp/Sources/ProjectView.swift
T

294 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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."
}
}
}
}
}