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

252 lines
8.8 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)
}
/// 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 }
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
}
if let url, isAccepted(url) {
DispatchQueue.main.async { inspect(url.path) }
} else {
DispatchQueue.main.async {
errorMessage = "Este arquivo não parece ser um projeto do Final Cut Pro."
}
}
}
return true
}
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
} else {
errorMessage = result?["error"] as? String ?? err ?? "Falha ao ler o projeto."
}
}
}
}
}