1import Foundation
2import CoreGraphics
3
4// All times are seconds (Double). Frames only appear at Fusion export and
5// timecode display, converted with the relevant fps.
6
7enum ClipKind: String, Codable {
8 case video, audio, storyboard
9}
10
11/// Which lane a clip lives on. Video/audio clips reference a video track by
12/// index (0-based; displayed as index+1). The storyboard lane and the Fusion
13/// comps band are singular special cases — there is only ever one of each, so
14/// they need no index. `.fusion` holds no clips; it exists so view state
15/// (hide/focus/priority) can key the Fusion band the same way as a track.
16enum TrackRef: Hashable, Codable {
17 case video(Int)
18 case storyboard
19 case fusion
20
21 /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion".
22 var wire: String {
23 switch self {
24 case .video(let i): return "v\(i)"
25 case .storyboard: return "storyboard"
26 case .fusion: return "fusion"
27 }
28 }
29 init?(wire: String) {
30 switch wire {
31 case "storyboard": self = .storyboard
32 case "fusion": self = .fusion
33 default:
34 guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil }
35 self = .video(i)
36 }
37 }
38 var videoIndex: Int? { if case .video(let i) = self { return i }; return nil }
39
40 init(from decoder: Decoder) throws {
41 let s = try decoder.singleValueContainer().decode(String.self)
42 guard let ref = TrackRef(wire: s) else {
43 throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath,
44 debugDescription: "bad TrackRef \(s)"))
45 }
46 self = ref
47 }
48 func encode(to encoder: Encoder) throws {
49 var c = encoder.singleValueContainer()
50 try c.encode(wire)
51 }
52}
53
54/// A video/audio lane. In the numbered model a track is *just a hue* — its
55/// index in `ProjectModel.tracks` is its number, so there is no id/order to
56/// keep in sync. The storyboard lane isn't stored here (it's implied by the
57/// presence of `.storyboard` clips) and uses a fixed hue.
58struct Track: Codable, Equatable {
59 var hue: Double = 0 // 0..1
60
61 init(hue: Double = 0) { self.hue = hue }
62
63 enum CodingKeys: String, CodingKey { case hue }
64 init(from decoder: Decoder) throws {
65 let c = try decoder.container(keyedBy: CodingKeys.self)
66 hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0
67 }
68}
69
70// Legacy (v1) track kind — retained only so pre-numbering `.sq` files still
71// decode during migration. New projects don't store this.
72enum TrackKind: String, Codable {
73 case video
74 case storyboard
75}
76
77struct ProjectModel: Codable, Equatable {
78 var fps: Double = 30.0
79 var media: [MediaItem] = []
80 var tracks: [Track] = [] // video lanes; array index == track number
81 var clips: [Clip] = []
82 var markers: [Marker] = [] // blue timeline markers (labelled or not)
83 var compsFolder: String? = nil // Fusion .comp folder for the comps band
84 var preferredTakes: [String] = [] // comp filenames marked as preferred
85 // Resolution of NEW storyboard panels, stored as concrete pixels rather
86 // than a float aspect ratio (which round-trips lossily and compares badly).
87 var boardWidth: Int = 1920
88 var boardHeight: Int = 1080
89
90 /// Fixed hue for the (singular) storyboard lane.
91 static let storyboardHue = 0.13
92
93 /// Aspect ratio derived from the stored resolution, for display/layout.
94 var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) }
95
96 init() {}
97
98 enum CodingKeys: String, CodingKey {
99 case fps, media, tracks, clips, markers, compsFolder, preferredTakes,
100 boardWidth, boardHeight
101 }
102 // Tolerant decoding so projects saved by older builds keep opening. This
103 // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated
104 // up front in `SequencerDocument`. Encoding is synthesized (all keys are
105 // stored properties — `boardAspect` is computed and never written).
106 init(from decoder: Decoder) throws {
107 let c = try decoder.container(keyedBy: CodingKeys.self)
108 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
109 media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? []
110 tracks = try c.decodeIfPresent([Track].self, forKey: .tracks) ?? []
111 clips = try c.decodeIfPresent([Clip].self, forKey: .clips) ?? []
112 markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? []
113 compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder)
114 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
115 if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w }
116 if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h }
117 }
118
119 /// New storyboard boards use the project's storyboard resolution.
120 func newBoard() -> Board {
121 var b = Board()
122 b.width = Double(max(16, boardWidth))
123 b.height = Double(max(16, boardHeight))
124 return b
125 }
126}
127
128// MARK: - Document envelope
129
130/// Portable, non-undoable view state saved alongside the model so a project
131/// opens looking the way it was left, on any machine. Deliberately kept OUT of
132/// `ProjectModel` so undo/redo never toggles visibility or zoom.
133struct TrackHeight: Codable, Equatable {
134 var track: TrackRef
135 var factor: Double
136}
137
138struct ViewState: Codable, Equatable {
139 var hiddenTracks: [TrackRef] = []
140 var focusedTracks: [TrackRef] = []
141 var trackHeights: [TrackHeight] = []
142 var laneScale: Double = 1
143 var snapping = true
144 var showFilmstrips = true
145 var subtitles = true // clover-transcript captions overlay
146 var previewsOnLeft = false
147 var priorityPane: TrackRef? = nil
148 var fusionHidden = false
149 var fusionFocus = false
150 /// Where the playhead was parked at save. Reopening resumes here, which
151 /// also tells the proxy cache which neighbourhood to keep warm/protected
152 /// for this project while its window is in the background.
153 var playhead: Double = 0
154
155 init() {}
156
157 enum CodingKeys: String, CodingKey {
158 case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping,
159 showFilmstrips, subtitles, previewsOnLeft, priorityPane,
160 fusionHidden, fusionFocus, playhead
161 }
162 init(from decoder: Decoder) throws {
163 let c = try decoder.container(keyedBy: CodingKeys.self)
164 hiddenTracks = try c.decodeIfPresent([TrackRef].self, forKey: .hiddenTracks) ?? []
165 focusedTracks = try c.decodeIfPresent([TrackRef].self, forKey: .focusedTracks) ?? []
166 trackHeights = try c.decodeIfPresent([TrackHeight].self, forKey: .trackHeights) ?? []
167 laneScale = try c.decodeIfPresent(Double.self, forKey: .laneScale) ?? 1
168 snapping = try c.decodeIfPresent(Bool.self, forKey: .snapping) ?? true
169 showFilmstrips = try c.decodeIfPresent(Bool.self, forKey: .showFilmstrips) ?? true
170 subtitles = try c.decodeIfPresent(Bool.self, forKey: .subtitles) ?? true
171 previewsOnLeft = try c.decodeIfPresent(Bool.self, forKey: .previewsOnLeft) ?? false
172 priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane)
173 fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false
174 fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false
175 playhead = try c.decodeIfPresent(Double.self, forKey: .playhead) ?? 0
176 }
177}
178
179/// The on-disk `.sq` envelope: a versioned wrapper around the undoable model
180/// plus portable view state. Reads both this shape and the legacy bare
181/// `ProjectModel` (v1, no envelope) so old files keep opening.
182struct SequencerDocument: Codable {
183 var formatVersion: Int = 2
184 var project: ProjectModel
185 var view: ViewState
186
187 init(project: ProjectModel, view: ViewState) {
188 self.project = project
189 self.view = view
190 }
191
192 enum CodingKeys: String, CodingKey { case formatVersion, project, view }
193 init(from decoder: Decoder) throws {
194 let c = try decoder.container(keyedBy: CodingKeys.self)
195 if c.contains(.project) {
196 // v2 envelope.
197 formatVersion = try c.decodeIfPresent(Int.self, forKey: .formatVersion) ?? 2
198 project = try c.decode(ProjectModel.self, forKey: .project)
199 view = try c.decodeIfPresent(ViewState.self, forKey: .view) ?? ViewState()
200 } else {
201 // v1: a bare ProjectModel with UUID-keyed tracks at the top level.
202 let legacy = try LegacyProject(from: decoder)
203 formatVersion = 2
204 project = ProjectModel(legacy: legacy)
205 view = ViewState()
206 }
207 }
208 func encode(to encoder: Encoder) throws {
209 var c = encoder.container(keyedBy: CodingKeys.self)
210 try c.encode(formatVersion, forKey: .formatVersion)
211 try c.encode(project, forKey: .project)
212 try c.encode(view, forKey: .view)
213 }
214}
215
216// MARK: - Legacy (v1) migration
217
218/// Mirrors the pre-numbering top-level `.sq` shape just enough to migrate it.
219/// `MediaItem`, `Marker`, and `Board` are unchanged, so they reuse their own
220/// tolerant decoders.
221private struct LegacyProject: Decodable {
222 var fps: Double
223 var media: [MediaItem]
224 var tracks: [LegacyTrack]
225 var clips: [LegacyClip]
226 var markers: [Marker]
227 var compsFolder: String?
228 var preferredTakes: [String]
229 var boardAspect: Double
230
231 enum CodingKeys: String, CodingKey {
232 case fps, media, tracks, clips, markers, compsFolder, preferredTakes, boardAspect
233 }
234 init(from decoder: Decoder) throws {
235 let c = try decoder.container(keyedBy: CodingKeys.self)
236 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
237 media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? []
238 tracks = try c.decodeIfPresent([LegacyTrack].self, forKey: .tracks) ?? []
239 clips = try c.decodeIfPresent([LegacyClip].self, forKey: .clips) ?? []
240 markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? []
241 compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder)
242 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
243 boardAspect = try c.decodeIfPresent(Double.self, forKey: .boardAspect) ?? 16.0 / 9.0
244 }
245}
246
247private struct LegacyTrack: Decodable {
248 var id: UUID
249 var order: Int
250 var hue: Double
251 var kind: TrackKind
252 enum CodingKeys: String, CodingKey { case id, order, hue, kind }
253 init(from decoder: Decoder) throws {
254 let c = try decoder.container(keyedBy: CodingKeys.self)
255 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
256 order = try c.decodeIfPresent(Int.self, forKey: .order) ?? 0
257 hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0
258 kind = try c.decodeIfPresent(TrackKind.self, forKey: .kind) ?? .video
259 }
260}
261
262private struct LegacyClip: Decodable {
263 var id: UUID
264 var kind: ClipKind
265 var mediaId: UUID?
266 var trackId: UUID
267 var start: Double
268 var srcIn: Double
269 var duration: Double
270 var speed: Double
271 var muted: Bool
272 var fadeIn: Double
273 var fadeOut: Double
274 var linkId: UUID?
275 var board: Board?
276 var newShot: Bool
277 enum CodingKeys: String, CodingKey {
278 case id, kind, mediaId, trackId, start, srcIn, duration, speed,
279 muted, fadeIn, fadeOut, linkId, board, newShot
280 }
281 init(from decoder: Decoder) throws {
282 let c = try decoder.container(keyedBy: CodingKeys.self)
283 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
284 kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video
285 mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId)
286 trackId = try c.decodeIfPresent(UUID.self, forKey: .trackId) ?? UUID()
287 start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0
288 srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0
289 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
290 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
291 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
292 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
293 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
294 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
295 board = try c.decodeIfPresent(Board.self, forKey: .board)
296 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false
297 }
298}
299
300extension ProjectModel {
301 /// Migrate a v1 (UUID-keyed) project into the numbered model: video tracks
302 /// sorted by their old `order` become indices 0…N; each clip's `trackId`
303 /// resolves to `.video(i)` or `.storyboard`; the float aspect becomes a
304 /// concrete resolution.
305 fileprivate init(legacy: LegacyProject) {
306 self.init()
307 fps = legacy.fps
308 media = legacy.media
309 markers = legacy.markers
310 compsFolder = legacy.compsFolder
311 preferredTakes = legacy.preferredTakes
312 boardHeight = 1080
313 boardWidth = Int((1080.0 * max(0.2, legacy.boardAspect)).rounded())
314
315 let videoTracks = legacy.tracks.filter { $0.kind == .video }
316 .sorted { $0.order < $1.order }
317 var refByUUID: [UUID: TrackRef] = [:]
318 for (i, t) in videoTracks.enumerated() { refByUUID[t.id] = .video(i) }
319 for t in legacy.tracks where t.kind == .storyboard { refByUUID[t.id] = .storyboard }
320 tracks = videoTracks.map { Track(hue: $0.hue) }
321
322 clips = legacy.clips.map { lc in
323 // Storyboard panels are pinned to the storyboard lane regardless of
324 // whatever track they referenced.
325 let ref: TrackRef = lc.kind == .storyboard
326 ? .storyboard
327 : (refByUUID[lc.trackId] ?? .video(0))
328 var c = Clip(mediaId: lc.mediaId, track: ref, start: lc.start, srcIn: lc.srcIn,
329 duration: lc.duration, kind: lc.kind, linkId: lc.linkId, board: lc.board)
330 c.id = lc.id
331 c.speed = lc.speed
332 c.muted = lc.muted
333 c.fadeIn = lc.fadeIn
334 c.fadeOut = lc.fadeOut
335 c.newShot = lc.newShot
336 return c
337 }
338 }
339}
340
341struct MediaItem: Codable, Equatable, Identifiable {
342 var id: UUID = UUID()
343 var path: String
344 var duration: Double = 0
345 var fps: Double = 30
346 var width: Int = 0
347 var height: Int = 0
348 var hasAudio: Bool = false
349 var isAudio: Bool = false // audio-only file (no video stream)
350 var cacheKey: String = ""
351
352 // isDirectory:false skips URL's hidden stat() — path lives on the NAS,
353 // and per-draw stats of a cold network volume freeze the whole UI.
354 var url: URL { URL(fileURLWithPath: path, isDirectory: false) }
355 var displayName: String { (path as NSString).lastPathComponent }
356
357 init(path: String) { self.path = path }
358
359 enum CodingKeys: String, CodingKey {
360 case id, path, duration, fps, width, height, hasAudio, isAudio, cacheKey
361 }
362 init(from decoder: Decoder) throws {
363 let c = try decoder.container(keyedBy: CodingKeys.self)
364 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
365 path = try c.decode(String.self, forKey: .path)
366 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 0
367 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
368 width = try c.decodeIfPresent(Int.self, forKey: .width) ?? 0
369 height = try c.decodeIfPresent(Int.self, forKey: .height) ?? 0
370 hasAudio = try c.decodeIfPresent(Bool.self, forKey: .hasAudio) ?? false
371 isAudio = try c.decodeIfPresent(Bool.self, forKey: .isAudio) ?? false
372 cacheKey = try c.decodeIfPresent(String.self, forKey: .cacheKey) ?? ""
373 }
374}
375
376// MARK: - Storyboard boards
377
378/// One vector object on a board's shape layer.
379struct BoardShape: Codable, Equatable, Identifiable {
380 enum Kind: String, Codable {
381 case rect, oval, triangle, star, ngon, text, image
382 }
383 var id: UUID = UUID()
384 var kind: Kind
385 var frame: CGRect
386 var color: [Double] = [0, 0, 0, 1] // rgba 0..1
387 var text: String = ""
388 var fontSize: Double = 48
389 var sides: Int = 5 // star points / ngon sides
390 var imagePath: String? = nil // image-reference shapes
391 var filled: Bool = true
392 var aboveRaster: Bool = false // "bring to top" puts it over the drawing layer
393}
394
395/// A storyboard panel: a shape (vector) layer that renders below a raster
396/// (drawing) layer. The raster lives on disk keyed by `id`; `revision` bumps
397/// whenever either layer changes so composite caches invalidate.
398struct Board: Codable, Equatable {
399 var id: UUID = UUID()
400 var revision: Int = 0
401 var shapes: [BoardShape] = []
402 var width: Double = 1600
403 var height: Double = 900
404 var size: CGSize { CGSize(width: width, height: height) }
405}
406
407struct Clip: Codable, Equatable, Identifiable {
408 var id: UUID = UUID()
409 var kind: ClipKind = .video
410 var mediaId: UUID? // nil for storyboard panels
411 var track: TrackRef // which lane this clip lives on
412 var start: Double // timeline seconds
413 var srcIn: Double // source seconds
414 var duration: Double // timeline seconds
415 var speed: Double = 1.0 // source seconds consumed per timeline second
416 var muted: Bool = false
417 var fadeIn: Double = 0 // audio fade lengths, timeline seconds
418 var fadeOut: Double = 0
419 var linkId: UUID? // clips sharing a linkId move/blade together (multicam)
420 var board: Board? // storyboard panel content
421 var newShot: Bool = false // storyboard: this panel starts a new shot number
422
423 var end: Double { start + duration }
424 /// Source seconds this clip consumes (constant under time stretch).
425 var sourceLength: Double { duration * speed }
426 var srcOut: Double { srcIn + sourceLength }
427
428 /// Source time for a timeline moment inside the clip.
429 func sourceTime(at t: Double) -> Double { srcIn + (t - start) * speed }
430
431 /// Inverse of `sourceTime(at:)`: the timeline moment inside the clip that
432 /// shows source second `s`. Clamped to the clip's own span so it can't jump
433 /// the playhead onto a neighbour.
434 func timelineTime(forSource s: Double) -> Double {
435 let t = speed != 0 ? start + (s - srcIn) / speed : start
436 return min(max(t, start), end)
437 }
438
439 init(mediaId: UUID?, track: TrackRef, start: Double, srcIn: Double,
440 duration: Double, kind: ClipKind = .video, linkId: UUID? = nil,
441 board: Board? = nil) {
442 self.mediaId = mediaId
443 self.track = track
444 self.start = start
445 self.srcIn = srcIn
446 self.duration = duration
447 self.kind = kind
448 self.linkId = linkId
449 self.board = board
450 }
451
452 enum CodingKeys: String, CodingKey {
453 case id, kind, mediaId, track, start, srcIn, duration, speed,
454 muted, fadeIn, fadeOut, linkId, board, newShot
455 }
456 init(from decoder: Decoder) throws {
457 let c = try decoder.container(keyedBy: CodingKeys.self)
458 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
459 kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video
460 mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId)
461 track = try c.decodeIfPresent(TrackRef.self, forKey: .track)
462 ?? (kind == .storyboard ? .storyboard : .video(0))
463 start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0
464 srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0
465 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
466 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
467 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
468 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
469 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
470 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
471 board = try c.decodeIfPresent(Board.self, forKey: .board)
472 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false
473 }
474}
475
476/// A timeline marker: a point in time the user parks a blue playhead on, with
477/// an optional short label. Independent of clips/tracks — it lives on the
478/// timeline itself.
479struct Marker: Codable, Equatable, Identifiable {
480 var id: UUID = UUID()
481 var time: Double // timeline seconds
482 var label: String = ""
483
484 init(id: UUID = UUID(), time: Double, label: String = "") {
485 self.id = id
486 self.time = time
487 self.label = label
488 }
489
490 enum CodingKeys: String, CodingKey { case id, time, label }
491 init(from decoder: Decoder) throws {
492 let c = try decoder.container(keyedBy: CodingKeys.self)
493 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
494 time = try c.decodeIfPresent(Double.self, forKey: .time) ?? 0
495 label = try c.decodeIfPresent(String.self, forKey: .label) ?? ""
496 }
497}
498
499/// A same-track overlap between two non-audio clips (audio is allowed to
500/// layer). `a` starts no later than `b`; the red range is [start, end).
501struct ClipOverlap: Equatable {
502 var a: Clip
503 var b: Clip
504 var track: TrackRef
505 var start: Double
506 var end: Double
507}
508
509extension ProjectModel {
510 func media(_ id: UUID?) -> MediaItem? {
511 guard let id else { return nil }
512 return media.first { $0.id == id }
513 }
514 func clip(_ id: UUID) -> Clip? { clips.first { $0.id == id } }
515
516 /// Hue for any lane, including the fixed storyboard hue.
517 func hue(for ref: TrackRef) -> Double {
518 switch ref {
519 case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0
520 case .storyboard: return Self.storyboardHue
521 case .fusion: return 0
522 }
523 }
524
525 var sortedMarkers: [Marker] { markers.sorted { $0.time < $1.time } }
526 /// Nearest marker strictly after `t` (for → navigation).
527 func nextMarker(after t: Double) -> Marker? {
528 sortedMarkers.first { $0.time > t + 1e-6 }
529 }
530 /// Nearest marker strictly before `t` (for ← navigation).
531 func prevMarker(before t: Double) -> Marker? {
532 sortedMarkers.last { $0.time < t - 1e-6 }
533 }
534
535 /// True when any storyboard panel exists (the storyboard lane is implied,
536 /// not stored in `tracks`).
537 var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } }
538
539 /// The lanes shown in the timeline/viewer, top to bottom: the storyboard
540 /// lane (if any panels exist) is pinned on top, then video tracks in index
541 /// order. Views index into this to map a row to a `TrackRef`.
542 var laneRefs: [TrackRef] {
543 var rows: [TrackRef] = []
544 if hasStoryboard { rows.append(.storyboard) }
545 for i in tracks.indices { rows.append(.video(i)) }
546 return rows
547 }
548 /// Row index of a lane in `laneRefs`, or nil if it isn't shown.
549 func row(of ref: TrackRef) -> Int? { laneRefs.firstIndex(of: ref) }
550
551 func clips(on ref: TrackRef) -> [Clip] {
552 clips.filter { $0.track == ref }.sorted { $0.start < $1.start }
553 }
554 /// Clips on video track `index`.
555 func clips(onVideo index: Int) -> [Clip] { clips(on: .video(index)) }
556
557 /// Expand a set of clip ids with all their link-mates.
558 func expandLinks(_ ids: Set<UUID>) -> Set<UUID> {
559 let linkIds = Set(clips.filter { ids.contains($0.id) }.compactMap(\.linkId))
560 guard !linkIds.isEmpty else { return ids }
561 return ids.union(clips.filter { $0.linkId.map(linkIds.contains) ?? false }.map(\.id))
562 }
563
564 /// Clip under a timeline moment on a lane. When clips overlap, the one
565 /// that starts latest wins (the "most recent cut").
566 func clipAt(track ref: TrackRef, time: Double, kind: ClipKind? = nil) -> Clip? {
567 clips
568 .filter {
569 $0.track == ref && time >= $0.start && time < $0.end
570 && (kind == nil || $0.kind == kind)
571 }
572 .max { $0.start < $1.start }
573 }
574
575 /// End of the meaningful content. Storyboard panels are start-only (the
576 /// last one extends "forever"), so only their starts count here.
577 var timelineDuration: Double {
578 let solid = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0
579 let lastPanel = clips.filter { $0.kind == .storyboard }.map(\.start).max()
580 .map { $0 + 5 } ?? 0
581 return max(solid, lastPanel)
582 }
583
584 /// Same-track overlaps between VIDEO clips (audio layers freely, and
585 /// storyboard panels are start-only points that can't overlap).
586 /// Overlap is the editing error the red highlight surfaces.
587 func overlaps(on ref: TrackRef? = nil) -> [ClipOverlap] {
588 var out: [ClipOverlap] = []
589 let grouped = Dictionary(grouping: clips.filter { $0.kind == .video },
590 by: \.track)
591 for (tref, arr) in grouped {
592 if let ref, tref != ref { continue }
593 // Tiebreak on the id only when starts collide — building a
594 // uuidString per comparison made sorting a dense lane allocate
595 // thousands of strings.
596 let sorted = arr.sorted {
597 $0.start != $1.start ? $0.start < $1.start
598 : $0.id.uuidString < $1.id.uuidString
599 }
600 for i in 0..<sorted.count {
601 let a = sorted[i]
602 for j in (i + 1)..<sorted.count {
603 let b = sorted[j]
604 // Sorted by start: once b starts at/after a's end, every
605 // later clip does too. Without this break the scan is
606 // O(n²) per lane — and it runs on every gesture update.
607 if b.start >= a.end - 1e-9 { break }
608 out.append(ClipOverlap(a: a, b: b, track: tref,
609 start: b.start, end: min(a.end, b.end)))
610 }
611 }
612 }
613 return out.sorted { $0.start < $1.start }
614 }
615
616 /// Deterministic, well-spread default hue for the track at a given index.
617 /// Golden-ratio spacing keeps neighbouring lanes visibly distinct while
618 /// staying stable across sessions (no more random colors on every add).
619 static func defaultHue(forIndex index: Int) -> Double {
620 let h = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1)
621 return h < 0 ? h + 1 : h
622 }
623
624 /// Append a new video track; returns its index.
625 @discardableResult
626 mutating func addTrack() -> Int {
627 let i = tracks.count
628 tracks.append(Track(hue: Self.defaultHue(forIndex: i)))
629 return i
630 }
631
632 /// Remove video track `index`, renumbering the clips above it down by one
633 /// (numbered tracks means a delete shifts every higher lane — the work
634 /// UUIDs used to make free). Callers ensure the track is empty first.
635 mutating func removeTrack(at index: Int) {
636 guard tracks.indices.contains(index) else { return }
637 tracks.remove(at: index)
638 for i in clips.indices {
639 if case .video(let n) = clips[i].track, n > index {
640 clips[i].track = .video(n - 1)
641 }
642 }
643 }
644
645 /// Storyboard panels in canonical (stored) order — the same ordering
646 /// `normalizeStoryboards` uses, so a panel's index here is stable across
647 /// saves. Drives the on-disk raster filenames (`Storyboard/NN.png`).
648 func orderedStoryboardPanels() -> [Clip] {
649 clips
650 .filter { $0.kind == .storyboard }
651 .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) }
652 }
653
654 /// Storyboard panels are start-only: each one lasts until the next panel
655 /// starts, and the last one extends past everything else ("forever").
656 /// Called after every mutation so stored durations always agree.
657 mutating func normalizeStoryboards() {
658 let fd = 1.0 / max(1, fps)
659 var panels = clips
660 .filter { $0.kind == .storyboard }
661 .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) }
662 guard !panels.isEmpty else { return }
663 // The storyboard sequence is anchored to the very start: the first
664 // panel always begins at 0:00.
665 if panels[0].start != 0 {
666 if let idx0 = clips.firstIndex(where: { $0.id == panels[0].id }) {
667 clips[idx0].start = 0
668 }
669 panels[0].start = 0
670 }
671 let solidEnd = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0
672 for (i, p) in panels.enumerated() {
673 guard let idx = clips.firstIndex(where: { $0.id == p.id }) else { continue }
674 let end = i + 1 < panels.count
675 ? panels[i + 1].start
676 : max(solidEnd + 5, p.start + 10)
677 clips[idx].duration = max(fd, end - p.start)
678 clips[idx].srcIn = 0
679 clips[idx].track = .storyboard
680 }
681 }
682
683 /// Automatic panel names: "1A, 1B, 2A, …" — the number is the shot, the
684 /// letter is the frame within it. Panels are gapless, so a new shot is
685 /// purely metadata: the newShot flag (set by N-split or the context menu).
686 func panelNames() -> [UUID: String] {
687 let panels = orderedStoryboardPanels()
688 guard !panels.isEmpty else { return [:] }
689 var names: [UUID: String] = [:]
690 var shot = 0
691 var frame = 0
692 for p in panels {
693 if shot == 0 || p.newShot {
694 shot += 1
695 frame = 0
696 }
697 var letters = ""
698 var n = frame
699 repeat {
700 letters = String(UnicodeScalar(65 + n % 26)!) + letters
701 n = n / 26 - 1
702 } while n >= 0
703 names[p.id] = "\(shot)\(letters)"
704 frame += 1
705 }
706 return names
707 }
708
709 /// Drop every empty track (keeping at least one). Only invoked by the
710 /// explicit "Delete Empty Tracks" action — empty tracks are allowed.
711 mutating func pruneEmptyTracks(keepAtLeast: Int = 1) {
712 var removable = max(0, tracks.count - keepAtLeast)
713 // Remove high-to-low so earlier indices stay valid across removals.
714 for idx in tracks.indices.reversed()
715 where removable > 0 && tracks.count > keepAtLeast && clips(onVideo: idx).isEmpty {
716 removeTrack(at: idx)
717 removable -= 1
718 }
719 }
720
721 /// Trailing empty tracks (the ones BELOW the last track holding any clip)
722 /// are ephemeral drop targets: drop something and the lane becomes real;
723 /// move it away and the lane disappears again. Called after finalized
724 /// mutations that can empty a bottom track (drags, deletes). Interior empty
725 /// tracks are left alone, and at least one video track always survives — so
726 /// dragging a clip down two rows still makes two tracks (the middle one is
727 /// interior).
728 mutating func pruneTrailingEmptyTracks() {
729 guard let lastUsed = tracks.indices.last(where: { !clips(onVideo: $0).isEmpty })
730 else { return } // nothing placed yet — leave the lanes alone
731 var idx = tracks.count - 1
732 while idx > lastUsed && tracks.count > 1 {
733 if clips(onVideo: idx).isEmpty { removeTrack(at: idx) }
734 idx -= 1
735 }
736 }
737}
738
739/// Fade envelope gain for an audio clip at a timeline moment, 0..1.
740func audioGain(_ clip: Clip, at t: Double) -> Double {
741 guard t >= clip.start, t < clip.end else { return 0 }
742 var g = 1.0
743 if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) }
744 if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) }
745 return max(0, min(1, g))
746}
747
748func timecodeString(frame: Int, fps: Double) -> String {
749 let fpsI = max(1, Int(fps.rounded()))
750 let total = frame / fpsI
751 return String(format: "%02d:%02d:%02d:%02d",
752 total / 3600, (total / 60) % 60, total % 60, frame % fpsI)
753}