| 1 | import Foundation |
| 2 | import AppKit |
| 3 | |
| 4 | /// Generates Fusion clipboard Lua: one Loader per clip, paste directly into |
| 5 | /// the Flow view. References ORIGINAL media paths, never proxies. |
| 6 | /// GlobalStart/End place the clip at its timeline position (project fps); |
| 7 | /// TrimIn/Out select the source range (source fps). |
| 8 | enum FusionExport { |
| 9 | |
| 10 | static func copySelectedClips() { |
| 11 | let store = DocumentContext.current.store |
| 12 | let project = store.project |
| 13 | let clips = project.clips |
| 14 | .filter { store.selection.contains($0.id) } |
| 15 | .sorted { $0.start < $1.start } |
| 16 | guard !clips.isEmpty else { |
| 17 | return |
| 18 | } |
| 19 | let lua = loaderLua(for: clips, project: project) |
| 20 | let pb = NSPasteboard.general |
| 21 | pb.clearContents() |
| 22 | pb.setString(lua, forType: .string) |
| 23 | } |
| 24 | |
| 25 | static func loaderLua(for clips: [Clip], project: ProjectModel) -> String { |
| 26 | var tools: [String] = [] |
| 27 | var lastName = "" |
| 28 | var usedNames = Set<String>() |
| 29 | for (i, clip) in clips.enumerated() { |
| 30 | guard let media = project.media(clip.mediaId) else { continue } |
| 31 | var name = "Loader_" + sanitize(media.url.deletingPathExtension().lastPathComponent) |
| 32 | var n = 1 |
| 33 | while usedNames.contains(name) { n += 1; name = name.replacingOccurrences(of: #"_\d+$"#, with: "", options: .regularExpression) + "_\(n)" } |
| 34 | usedNames.insert(name) |
| 35 | lastName = name |
| 36 | |
| 37 | let srcFps = media.fps |
| 38 | let projFps = project.fps |
| 39 | // Source range (speed-aware: stretched clips still reference |
| 40 | // their true source frames — Loaders don't retime). |
| 41 | let trimIn = Int((clip.srcIn * srcFps).rounded()) |
| 42 | let trimOut = max(trimIn, Int((clip.srcOut * srcFps).rounded()) - 1) |
| 43 | let globalStart = Int((clip.start * projFps).rounded()) |
| 44 | let globalEnd = max(globalStart, globalStart + Int((clip.duration * projFps).rounded()) - 1) |
| 45 | let length = max(1, Int((media.duration * srcFps).rounded())) |
| 46 | let posX = Double(i % 5) * 130.0 |
| 47 | let posY = Double(i / 5) * 50.0 |
| 48 | |
| 49 | tools.append(""" |
| 50 | \(name) = Loader { |
| 51 | Clips = { |
| 52 | Clip { |
| 53 | ID = "Clip1", |
| 54 | Filename = "\(escapeLua(media.path))", |
| 55 | FormatID = "QuickTimeMovies", |
| 56 | Length = \(length), |
| 57 | Multiframe = true, |
| 58 | TrimIn = \(trimIn), |
| 59 | TrimOut = \(trimOut), |
| 60 | ExtendFirst = 0, |
| 61 | ExtendLast = 0, |
| 62 | Loop = 0, |
| 63 | AspectMode = 0, |
| 64 | Depth = 0, |
| 65 | TimeCode = 0, |
| 66 | GlobalStart = \(globalStart), |
| 67 | GlobalEnd = \(globalEnd) |
| 68 | } |
| 69 | }, |
| 70 | CtrlWZoom = false, |
| 71 | ViewInfo = OperatorInfo { Pos = { \(posX), \(posY) } }, |
| 72 | } |
| 73 | """) |
| 74 | } |
| 75 | return """ |
| 76 | { |
| 77 | Tools = ordered() { |
| 78 | \(tools.joined(separator: ",\n")) |
| 79 | }, |
| 80 | ActiveTool = "\(lastName)" |
| 81 | } |
| 82 | """ |
| 83 | } |
| 84 | |
| 85 | private static func sanitize(_ s: String) -> String { |
| 86 | var out = s.map { c -> Character in |
| 87 | (c.isLetter && c.isASCII) || (c.isNumber && c.isASCII) ? c : "_" |
| 88 | } |
| 89 | if let first = out.first, first.isNumber { out.insert("_", at: 0) } |
| 90 | return out.isEmpty ? "Clip" : String(out) |
| 91 | } |
| 92 | |
| 93 | private static func escapeLua(_ s: String) -> String { |
| 94 | s.replacingOccurrences(of: "\\", with: "\\\\") |
| 95 | .replacingOccurrences(of: "\"", with: "\\\"") |
| 96 | } |
| 97 | |
| 98 | private static func post(_ text: String) { |
| 99 | NotificationCenter.default.post(name: .transientStatus, object: nil, userInfo: ["text": text]) |
| 100 | } |
| 101 | } |