1import Foundation
2import 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).
8enum 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}