| 1 | import Foundation |
| 2 | import CoreGraphics |
| 3 | |
| 4 | // All times are seconds (Double). Frames only appear at Fusion export and |
| 5 | // timecode display, converted with the relevant fps. |
| 6 | |
| 7 | enum 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. |
| 16 | enum 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. |
| 58 | struct 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. |
| 72 | enum TrackKind: String, Codable { |
| 73 | case video |
| 74 | case storyboard |
| 75 | } |
| 76 | |
| 77 | struct 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. |
| 133 | struct TrackHeight: Codable, Equatable { |
| 134 | var track: TrackRef |
| 135 | var factor: Double |
| 136 | } |
| 137 | |
| 138 | struct 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. |
| 182 | struct 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. |
| 221 | private 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 | |
| 247 | private 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 | |
| 262 | private 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 | |
| 300 | extension 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 | |
| 341 | struct 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. |
| 379 | struct 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. |
| 398 | struct 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 | |
| 407 | struct 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. |
| 479 | struct 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). |
| 501 | struct ClipOverlap: Equatable { |
| 502 | var a: Clip |
| 503 | var b: Clip |
| 504 | var track: TrackRef |
| 505 | var start: Double |
| 506 | var end: Double |
| 507 | } |
| 508 | |
| 509 | extension 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. |
| 740 | func 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 | |
| 748 | func 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 | } |