| 1 | import AppKit |
| 2 | |
| 3 | /// Dev-only per-section draw profiler (enabled by the `--perftest` harness). |
| 4 | /// Zero overhead when `on` is false. |
| 5 | enum DrawProf { |
| 6 | static var on = false |
| 7 | static var acc: [String: Double] = [:] |
| 8 | static var order: [String] = [] |
| 9 | static var thumbHits = 0 // filmstrip images actually blitted (cache hits) |
| 10 | static var tileRenders = 0 // scene tiles rasterized (cache misses) |
| 11 | static var tileBlits = 0 // scene tiles blitted (cache hits + fresh) |
| 12 | @inline(__always) static func t<T>(_ label: String, _ body: () -> T) -> T { |
| 13 | // Main-thread only: lanes rasterize in parallel, and racing the |
| 14 | // accumulator dictionaries would crash. Off-main work is simply |
| 15 | // not attributed (the enclosing main-thread section still is). |
| 16 | if !on || !Thread.isMainThread { return body() } |
| 17 | let t0 = DispatchTime.now().uptimeNanoseconds |
| 18 | let r = body() |
| 19 | let dt = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6 |
| 20 | if acc[label] == nil { order.append(label) } |
| 21 | acc[label, default: 0] += dt |
| 22 | return r |
| 23 | } |
| 24 | static func reset() { acc = [:]; order = [] } |
| 25 | static func report(frames: Int) { |
| 26 | for k in order { |
| 27 | print(String(format: " %-12@ %7.3f ms/frame", k as NSString, |
| 28 | acc[k]! / Double(frames))) |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// The timeline: ruler, Fusion comps band, track lanes, clips, playhead. |
| 34 | /// Tracks are unnamed and color-coded; new tracks appear dynamically when a |
| 35 | /// clip is dragged below the last lane (two rows down = two new tracks). |
| 36 | /// Empty tracks are allowed. All edits are frame-quantized and run through |
| 37 | /// Store gestures so every drag is one undo step. |
| 38 | final class TimelineView: NSView { |
| 39 | |
| 40 | // View state |
| 41 | private var pxPerSecond: Double = 20 |
| 42 | private var originSecond: Double = -1 |
| 43 | private var scrollY: CGFloat = 0 // vertical track scroll offset |
| 44 | private let rulerH: CGFloat = 26 |
| 45 | private let baseLaneH: CGFloat = 64 |
| 46 | private let laneGap: CGFloat = 4 |
| 47 | private let headerW: CGFloat = 26 |
| 48 | |
| 49 | /// The document context this view belongs to — its store, playback clock, |
| 50 | /// comps scanner and storyboard rasters. A thin facade over the shared |
| 51 | /// singletons for now; becomes an injected per-document instance later. |
| 52 | var ctx: DocumentContext = .headless { |
| 53 | didSet { |
| 54 | guard oldValue !== ctx else { return } |
| 55 | // `.playheadChanged` is per-document (on ctx.notify) — re-point it |
| 56 | // when the document context is injected. |
| 57 | oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) |
| 58 | ctx.notify.addObserver(self, selector: #selector(playheadMoved), |
| 59 | name: .playheadChanged, object: nil) |
| 60 | // New document ⇒ new model; the scene cache must rebuild even though |
| 61 | // no `.projectChanged` fires for the context swap itself. |
| 62 | sceneDirty = true |
| 63 | stripKey = nil |
| 64 | laneRefsCache = nil |
| 65 | flushTiles() |
| 66 | } |
| 67 | } |
| 68 | private var store: Store { ctx.store } |
| 69 | private var project: ProjectModel { ctx.store.project } |
| 70 | |
| 71 | /// The model's `laneRefs` getter scans EVERY clip (`hasStoryboard`) — |
| 72 | /// O(clips). The draw path reaches it per clip drawn (`drawClip → |
| 73 | /// clipRect → laneRect`), which is quadratic on big projects: 98% CPU |
| 74 | /// redraw storms. Cache it; invalidated on `.projectChanged`/ |
| 75 | /// `.viewOptionsChanged` (redraw()) and on document swap (ctx.didSet). |
| 76 | private var laneRefsCache: [TrackRef]? |
| 77 | private var laneRows: [TrackRef] { |
| 78 | if let rows = laneRefsCache { return rows } |
| 79 | let rows = project.laneRefs |
| 80 | laneRefsCache = rows |
| 81 | return rows |
| 82 | } |
| 83 | private var playback: PlaybackController { ctx.playback } |
| 84 | private var comps: FusionComps { ctx.comps } |
| 85 | private var boards: BoardStore { ctx.boards } |
| 86 | private var session: SessionState { ctx.session } |
| 87 | |
| 88 | override var isFlipped: Bool { true } |
| 89 | override var acceptsFirstResponder: Bool { true } |
| 90 | |
| 91 | // MARK: - Init |
| 92 | |
| 93 | override init(frame: NSRect) { |
| 94 | super.init(frame: frame) |
| 95 | registerForDraggedTypes([.fileURL]) |
| 96 | // Only model/view-option changes invalidate the grouped-clip scene |
| 97 | // cache. Selection changes just refresh the link-mate cache, and |
| 98 | // media-status/comps changes (thumbnails landing, comps rescans) only |
| 99 | // need a repaint — none of them regroup or re-sort thousands of clips. |
| 100 | for name: Notification.Name in [.projectChanged, .viewOptionsChanged] { |
| 101 | NotificationCenter.default.addObserver(self, selector: #selector(redraw), |
| 102 | name: name, object: nil) |
| 103 | } |
| 104 | NotificationCenter.default.addObserver(self, selector: #selector(selectionRedraw), |
| 105 | name: .selectionChanged, object: nil) |
| 106 | NotificationCenter.default.addObserver(self, selector: #selector(mediaRedraw(_:)), |
| 107 | name: .mediaStatusChanged, object: nil) |
| 108 | // The comps band isn't tiled — a comps rescan is repaint-only. |
| 109 | NotificationCenter.default.addObserver(self, selector: #selector(repaintOnly), |
| 110 | name: .compsChanged, object: nil) |
| 111 | // Per-document: bound against the current (headless) ctx here, re-bound |
| 112 | // when a real ctx is injected (see `ctx.didSet`). |
| 113 | ctx.notify.addObserver(self, selector: #selector(playheadMoved), |
| 114 | name: .playheadChanged, object: nil) |
| 115 | NotificationCenter.default.addObserver(self, selector: #selector(revealClip(_:)), |
| 116 | name: .revealClip, object: nil) |
| 117 | } |
| 118 | |
| 119 | required init?(coder: NSCoder) { fatalError() } |
| 120 | |
| 121 | @objc private func redraw() { |
| 122 | sceneDirty = true |
| 123 | stripKey = nil |
| 124 | laneRefsCache = nil |
| 125 | flushTiles() |
| 126 | needsDisplay = true |
| 127 | } |
| 128 | |
| 129 | @objc private func repaintOnly() { needsDisplay = true } |
| 130 | |
| 131 | /// `.mediaStatusChanged` is posted for two very different things: scene |
| 132 | /// pixels changing (a filmstrip/waveform/board raster landed — posts carry |
| 133 | /// `scene: true`) and proxy-chunk bookkeeping (status bar / viewer badges — |
| 134 | /// no timeline pixels). Only the former invalidates the tile cache; chunk |
| 135 | /// churn during playback must not force full scene re-renders. |
| 136 | @objc private func mediaRedraw(_ note: Notification) { |
| 137 | if note.userInfo?["scene"] != nil { flushTiles() } |
| 138 | stripKey = nil // chunk states changed — recompute the strip's runs |
| 139 | needsDisplay = true |
| 140 | } |
| 141 | |
| 142 | @objc private func selectionRedraw() { |
| 143 | // Link-mates of the selection outline aqua; recompute just that set — |
| 144 | // a selection click must not regroup/re-sort the whole project. |
| 145 | if !sceneDirty { |
| 146 | linkedSelectionCache = project.expandLinks(store.selection) |
| 147 | .subtracting(store.selection) |
| 148 | } |
| 149 | flushTiles() // selection tint/borders are baked into the rasters |
| 150 | needsDisplay = true |
| 151 | } |
| 152 | |
| 153 | @objc private func playheadMoved() { |
| 154 | // Auto-follow while playing. |
| 155 | if playback.isPlaying { |
| 156 | let x = xFor(playback.playhead) |
| 157 | if x > bounds.width - 60 || x < headerW { |
| 158 | originSecond = playback.playhead |
| 159 | - 0.1 * Double(bounds.width) / pxPerSecond |
| 160 | } |
| 161 | } |
| 162 | needsDisplay = true |
| 163 | } |
| 164 | |
| 165 | /// Scroll so a clip is on screen (viewer cells post this on click). |
| 166 | @objc private func revealClip(_ note: Notification) { |
| 167 | guard let id = note.userInfo?["clipId"] as? UUID, |
| 168 | let clip = project.clip(id) else { return } |
| 169 | let x0 = xFor(clip.start), x1 = xFor(clip.end) |
| 170 | if x1 < headerW + 20 || x0 > bounds.width - 20 { |
| 171 | originSecond = clip.start - 0.15 * Double(bounds.width) / pxPerSecond |
| 172 | } |
| 173 | needsDisplay = true |
| 174 | } |
| 175 | |
| 176 | // MARK: - Coordinates |
| 177 | |
| 178 | private func xFor(_ seconds: Double) -> CGFloat { |
| 179 | headerW + CGFloat((seconds - originSecond) * pxPerSecond) |
| 180 | } |
| 181 | private func secondsFor(_ x: CGFloat) -> Double { |
| 182 | originSecond + Double(x - headerW) / pxPerSecond |
| 183 | } |
| 184 | private func quantize(_ seconds: Double) -> Double { |
| 185 | let fps = project.fps |
| 186 | return (seconds * fps).rounded() / fps |
| 187 | } |
| 188 | private var frameDur: Double { 1.0 / project.fps } |
| 189 | |
| 190 | /// The optimization strip: a thin band under the ruler showing, per |
| 191 | /// timeline column, how optimized the media the visible tracks need there |
| 192 | /// is (see drawOptimizeStrip). |
| 193 | private let stripH: CGFloat = 5 |
| 194 | private var fusionBandH: CGFloat { comps.visible ? 46 : 0 } |
| 195 | private var fusionTop: CGFloat { rulerH + stripH } |
| 196 | private var lanesTop: CGFloat { rulerH + stripH + fusionBandH } |
| 197 | |
| 198 | private func laneHeight(_ ref: TrackRef) -> CGFloat { |
| 199 | // Device-pixel quantized so lane rasters (scene tiles) blit without |
| 200 | // resampling; the ≤ half-device-pixel rounding is imperceptible. |
| 201 | quantized(max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1))) |
| 202 | } |
| 203 | private var defaultLaneH: CGFloat { quantized(max(24, baseLaneH * session.laneScale)) } |
| 204 | |
| 205 | /// Total height of all lanes (for vertical scroll clamping). |
| 206 | private var lanesContentHeight: CGFloat { |
| 207 | laneRows.reduce(laneGap) { $0 + laneHeight($1) + laneGap } |
| 208 | } |
| 209 | private var maxScrollY: CGFloat { |
| 210 | max(0, lanesContentHeight - (bounds.height - lanesTop) + defaultLaneH) |
| 211 | } |
| 212 | |
| 213 | private func laneRect(row: Int) -> NSRect { |
| 214 | let rows = laneRows |
| 215 | var y = lanesTop + laneGap - scrollY |
| 216 | for (i, ref) in rows.enumerated() { |
| 217 | let h = laneHeight(ref) |
| 218 | if i == row { |
| 219 | return NSRect(x: 0, y: y, width: bounds.width, height: h) |
| 220 | } |
| 221 | y += h + laneGap |
| 222 | } |
| 223 | let extra = CGFloat(row - rows.count) |
| 224 | return NSRect(x: 0, y: y + extra * (defaultLaneH + laneGap), |
| 225 | width: bounds.width, height: defaultLaneH) |
| 226 | } |
| 227 | |
| 228 | private func rowAt(y: CGFloat) -> Int? { |
| 229 | guard y > lanesTop else { return nil } |
| 230 | var yy = lanesTop + laneGap - scrollY |
| 231 | let rows = laneRows |
| 232 | for (i, ref) in rows.enumerated() { |
| 233 | let h = laneHeight(ref) |
| 234 | if y < yy + h + laneGap { return i } |
| 235 | yy += h + laneGap |
| 236 | } |
| 237 | return rows.count + max(0, Int((y - yy) / (defaultLaneH + laneGap))) |
| 238 | } |
| 239 | |
| 240 | /// Row whose bottom edge is under the cursor (for track-height resizing). |
| 241 | private func trackBoundaryAt(y: CGFloat) -> Int? { |
| 242 | guard y > lanesTop else { return nil } |
| 243 | let rows = laneRows |
| 244 | var yy = lanesTop + laneGap - scrollY |
| 245 | for (i, ref) in rows.enumerated() { |
| 246 | yy += laneHeight(ref) |
| 247 | if abs(y - yy) <= 4 { return i } |
| 248 | yy += laneGap |
| 249 | } |
| 250 | return nil |
| 251 | } |
| 252 | |
| 253 | private func clipRect(_ clip: Clip, row: Int) -> NSRect { |
| 254 | let lane = laneRect(row: row) |
| 255 | let x0 = xFor(clip.start), x1 = xFor(clip.end) |
| 256 | return NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height) |
| 257 | } |
| 258 | |
| 259 | /// The lane shown at a row, or nil past the last real lane (ghost rows). |
| 260 | private func laneRef(row: Int) -> TrackRef? { |
| 261 | let rows = laneRows |
| 262 | return rows.indices.contains(row) ? rows[row] : nil |
| 263 | } |
| 264 | |
| 265 | private func clipAt(point: NSPoint) -> (clip: Clip, row: Int)? { |
| 266 | guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } |
| 267 | // The cached per-lane arrays are already start-sorted — no need to |
| 268 | // re-filter and re-sort the whole project per mouse event. |
| 269 | rebuildSceneIfNeeded() |
| 270 | // Later clips draw on top, so hit-test in reverse. |
| 271 | for clip in (clipsByLane[ref] ?? []).reversed() { |
| 272 | if clipRect(clip, row: row).contains(point) { return (clip, row) } |
| 273 | } |
| 274 | return nil |
| 275 | } |
| 276 | |
| 277 | private func overlapAt(point: NSPoint) -> ClipOverlap? { |
| 278 | guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil } |
| 279 | rebuildSceneIfNeeded() |
| 280 | for o in overlapsByLane[ref] ?? [] { |
| 281 | let lane = laneRect(row: row) |
| 282 | let r = NSRect(x: xFor(o.start), y: lane.minY, |
| 283 | width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height) |
| 284 | if r.contains(point) { return o } |
| 285 | } |
| 286 | return nil |
| 287 | } |
| 288 | |
| 289 | // MARK: - Drawing |
| 290 | |
| 291 | private var panelNamesCache: [UUID: String] = [:] |
| 292 | private var linkedSelectionCache: Set<UUID> = [] |
| 293 | // Per-lane clips (sorted by start) and overlap ranges, grouped ONCE per |
| 294 | // model change rather than re-filtered/re-sorted for every lane on every |
| 295 | // frame. Rebuilt lazily when `sceneDirty` is set — which `redraw()` does on |
| 296 | // any model/selection/media/view/comps change. Scrolling, zooming and the |
| 297 | // 60 Hz playhead only set `needsDisplay` (not `sceneDirty`), so a pan over a |
| 298 | // large project is now a redraw of cached geometry, not a full recompute. |
| 299 | private var clipsByLane: [TrackRef: [Clip]] = [:] |
| 300 | private var overlapsByLane: [TrackRef: [ClipOverlap]] = [:] |
| 301 | /// Ids of clips that are part of an overlap, per lane — hoisted out of |
| 302 | /// `drawLane`, which used to rebuild this Set per lane per frame. |
| 303 | private var overlapIdsByLane: [TrackRef: Set<UUID>] = [:] |
| 304 | /// Longest clip duration per lane — bounds the binary-searched draw/culling |
| 305 | /// window (a clip can start at most this far left of the view and still be |
| 306 | /// visible). |
| 307 | private var maxClipDurByLane: [TrackRef: Double] = [:] |
| 308 | /// `project.timelineDuration` scans every clip; cached here so per-frame |
| 309 | /// chrome (scrollbars, origin clamping) doesn't rescan 4k+ clips. |
| 310 | private var cachedTimelineDuration: Double = 0 |
| 311 | private var mediaById: [UUID: MediaItem] = [:] |
| 312 | private var sceneDirty = true |
| 313 | |
| 314 | /// Clip-title text attributes — hoisted out of the per-clip loop so we |
| 315 | /// don't rebuild the dictionary (and re-resolve the font) for every clip on |
| 316 | /// every frame. |
| 317 | private static let titleAttrs: [NSAttributedString.Key: Any] = [ |
| 318 | .font: NSFont.systemFont(ofSize: 9.5, weight: .medium), |
| 319 | .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1), |
| 320 | ] |
| 321 | |
| 322 | /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText |
| 323 | /// layout per call — ~200 visible labels re-laid-out every cold frame, |
| 324 | /// even though a multicam project has a handful of distinct names. Layout |
| 325 | /// once per distinct title, then `CTLineDraw` is a glyph blit. Capped: |
| 326 | /// titles are media names + panel names, a small closed set in practice. |
| 327 | private static var titleLineCache: [String: (line: CTLine, ascent: CGFloat)] = [:] |
| 328 | private static let titleLineLock = NSLock() // lanes rasterize in parallel |
| 329 | private static func titleLine(_ s: String) -> (line: CTLine, ascent: CGFloat) { |
| 330 | titleLineLock.lock() |
| 331 | defer { titleLineLock.unlock() } |
| 332 | if let c = titleLineCache[s] { return c } |
| 333 | if titleLineCache.count > 512 { titleLineCache.removeAll(keepingCapacity: true) } |
| 334 | let line = CTLineCreateWithAttributedString( |
| 335 | NSAttributedString(string: s, attributes: titleAttrs)) |
| 336 | var ascent: CGFloat = 0 |
| 337 | CTLineGetTypographicBounds(line, &ascent, nil, nil) |
| 338 | let entry = (line, ascent) |
| 339 | titleLineCache[s] = entry |
| 340 | return entry |
| 341 | } |
| 342 | |
| 343 | /// Draw a cached title line with its top-left at `point` in the flipped |
| 344 | /// view space — positioned to match what `NSString.draw(at:)` produced. |
| 345 | private func drawTitle(_ s: String, at point: NSPoint) { |
| 346 | guard let cg = NSGraphicsContext.current?.cgContext else { return } |
| 347 | let (line, ascent) = Self.titleLine(s) |
| 348 | cg.saveGState() |
| 349 | // Flipped context: un-flip locally for glyph drawing. The box-top to |
| 350 | // baseline offset is the line's ascent, matching NSString.draw(at:). |
| 351 | cg.translateBy(x: point.x, y: point.y + ascent) |
| 352 | cg.scaleBy(x: 1, y: -1) |
| 353 | cg.textMatrix = .identity |
| 354 | cg.textPosition = .zero |
| 355 | CTLineDraw(line, cg) |
| 356 | cg.restoreGState() |
| 357 | } |
| 358 | |
| 359 | /// SF Symbols, tinted and baked into flat bitmaps, cached by `key`. Building |
| 360 | /// an SF Symbol and tinting it (`tinted` does a `lockFocus` composite) *per |
| 361 | /// draw* was the single biggest timeline draw cost: nearly every clip is |
| 362 | /// linked, so the link/mute badges alone ran hundreds of times a frame, and |
| 363 | /// the track-header buttons re-baked every frame too. Cached, drawing a badge |
| 364 | /// is a plain blit. Main-thread only (all drawing is). |
| 365 | private static var symbolCache: [String: NSImage] = [:] |
| 366 | private static let symbolCacheLock = NSLock() // lanes rasterize in parallel |
| 367 | static func bakedSymbol(_ name: String, pointSize: CGFloat = 0, |
| 368 | weight: NSFont.Weight = .regular, |
| 369 | tint: NSColor, key: String) -> NSImage? { |
| 370 | symbolCacheLock.lock() |
| 371 | defer { symbolCacheLock.unlock() } |
| 372 | if let img = symbolCache[key] { return img } |
| 373 | var base = NSImage(systemSymbolName: name, accessibilityDescription: name) |
| 374 | if pointSize > 0 { |
| 375 | base = base?.withSymbolConfiguration(.init(pointSize: pointSize, weight: weight)) |
| 376 | } |
| 377 | guard let tinted = base?.tinted(tint) else { return nil } |
| 378 | // Bake into a 2× bitmap so the flattened badge stays crisp on Retina |
| 379 | // (a plain `lockFocus` would capture at whatever scale is current). |
| 380 | let size = tinted.size |
| 381 | guard size.width > 0, size.height > 0, |
| 382 | let rep = NSBitmapImageRep( |
| 383 | bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), |
| 384 | pixelsHigh: Int(size.height * 2), bitsPerSample: 8, samplesPerPixel: 4, |
| 385 | hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, |
| 386 | bytesPerRow: 0, bitsPerPixel: 0) else { return nil } |
| 387 | rep.size = size |
| 388 | NSGraphicsContext.saveGraphicsState() |
| 389 | NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) |
| 390 | tinted.draw(in: NSRect(origin: .zero, size: size)) |
| 391 | NSGraphicsContext.restoreGraphicsState() |
| 392 | let baked = NSImage(size: size) |
| 393 | baked.addRepresentation(rep) |
| 394 | symbolCache[key] = baked |
| 395 | return baked |
| 396 | } |
| 397 | private static var mutedBadge: NSImage? { |
| 398 | bakedSymbol("speaker.slash.fill", tint: .white, key: "badge.muted") |
| 399 | } |
| 400 | private static var linkBadge: NSImage? { |
| 401 | bakedSymbol("link", tint: .white, key: "badge.link") |
| 402 | } |
| 403 | |
| 404 | /// Index range of a lane's (start-sorted) clips that can intersect the |
| 405 | /// time window — binary-searched so a frame's work is proportional to |
| 406 | /// what's on screen, not to how far into a 10-hour project it sits. The |
| 407 | /// lower bound backs off by the lane's longest clip (any clip starting |
| 408 | /// earlier than that has necessarily ended before `left`); the upper bound |
| 409 | /// is the first start past `right`. Callers still skip the odd short clip |
| 410 | /// inside the backoff span with an `end < left` check. |
| 411 | private func visibleIndexRange(_ clips: [Clip], lane: TrackRef, |
| 412 | left: Double, right: Double) -> Range<Int> { |
| 413 | let backoff = left - (maxClipDurByLane[lane] ?? 0) |
| 414 | var lo = 0, hi = clips.count |
| 415 | while lo < hi { // first index with start >= backoff |
| 416 | let mid = (lo + hi) / 2 |
| 417 | if clips[mid].start < backoff { lo = mid + 1 } else { hi = mid } |
| 418 | } |
| 419 | let first = lo |
| 420 | hi = clips.count |
| 421 | while lo < hi { // first index with start > right |
| 422 | let mid = (lo + hi) / 2 |
| 423 | if clips[mid].start <= right { lo = mid + 1 } else { hi = mid } |
| 424 | } |
| 425 | return first..<lo |
| 426 | } |
| 427 | |
| 428 | private func rebuildSceneIfNeeded() { |
| 429 | guard sceneDirty else { return } |
| 430 | panelNamesCache = project.panelNames() |
| 431 | // Link-mates of the selection get an aqua outline (they act selected). |
| 432 | linkedSelectionCache = project.expandLinks(store.selection) |
| 433 | .subtracting(store.selection) |
| 434 | clipsByLane = Dictionary(grouping: project.clips, by: \.track) |
| 435 | for ref in clipsByLane.keys { |
| 436 | clipsByLane[ref]?.sort { $0.start < $1.start } |
| 437 | } |
| 438 | // `overlaps()` (no arg) computes every lane in one pass; grouping by |
| 439 | // track yields the same per-lane arrays `overlaps(on:)` returned. |
| 440 | overlapsByLane = Dictionary(grouping: project.overlaps(), by: \.track) |
| 441 | overlapIdsByLane = overlapsByLane.mapValues { |
| 442 | Set($0.flatMap { [$0.a.id, $0.b.id] }) |
| 443 | } |
| 444 | maxClipDurByLane = clipsByLane.mapValues { |
| 445 | $0.map(\.duration).max() ?? 0 |
| 446 | } |
| 447 | cachedTimelineDuration = project.timelineDuration |
| 448 | mediaById = Dictionary(project.media.map { ($0.id, $0) }, |
| 449 | uniquingKeysWith: { a, _ in a }) |
| 450 | // Per-lane colours: the track colour and its black-blended body/strip |
| 451 | // shades depend only on the lane's hue, but were re-derived (an NSColor |
| 452 | // alloc + two colour-space `blended()` conversions) for every clip every |
| 453 | // frame. Compute them once per lane here. |
| 454 | laneColorCache = [:] |
| 455 | for ref in laneRows { |
| 456 | let base = trackColor(ref) |
| 457 | laneColorCache[ref] = LaneColors( |
| 458 | base: base, |
| 459 | videoBody: base.blended(withFraction: 0.75, of: .black) ?? base, |
| 460 | audioBody: base.blended(withFraction: 0.82, of: .black) ?? base, |
| 461 | strip: (base.blended(withFraction: 0.5, of: .black) ?? base) |
| 462 | .withAlphaComponent(0.85)) |
| 463 | } |
| 464 | sceneDirty = false |
| 465 | } |
| 466 | |
| 467 | private struct LaneColors { |
| 468 | let base: NSColor // borders, waveform, filmstrip tint |
| 469 | let videoBody: NSColor // video clip body fill |
| 470 | let audioBody: NSColor // audio clip body fill |
| 471 | let strip: NSColor // title-strip fill |
| 472 | } |
| 473 | private var laneColorCache: [TrackRef: LaneColors] = [:] |
| 474 | private func laneColors(_ ref: TrackRef) -> LaneColors { |
| 475 | if let c = laneColorCache[ref] { return c } |
| 476 | let base = trackColor(ref) |
| 477 | return LaneColors(base: base, |
| 478 | videoBody: base.blended(withFraction: 0.75, of: .black) ?? base, |
| 479 | audioBody: base.blended(withFraction: 0.82, of: .black) ?? base, |
| 480 | strip: (base.blended(withFraction: 0.5, of: .black) ?? base) |
| 481 | .withAlphaComponent(0.85)) |
| 482 | } |
| 483 | |
| 484 | /// Clips intersecting the visible time window, across all lanes. Cheap: the |
| 485 | /// per-lane arrays are sorted, so it culls the same way the draw loop does. |
| 486 | private func visibleClipCount() -> Int { |
| 487 | let left = originSecond |
| 488 | let right = originSecond + Double(bounds.width - headerW) / pxPerSecond |
| 489 | var n = 0 |
| 490 | for ref in laneRows { |
| 491 | let clips = clipsByLane[ref] ?? [] |
| 492 | for i in visibleIndexRange(clips, lane: ref, left: left, right: right) |
| 493 | where clips[i].end >= left { |
| 494 | n += 1 |
| 495 | } |
| 496 | } |
| 497 | return n |
| 498 | } |
| 499 | |
| 500 | /// Below this on-screen width a clip physically cannot show its corner |
| 501 | /// radius, title strip text, badges, or a meaningful filmstrip/waveform |
| 502 | /// column — so `drawClip` takes a flat-rect fast path that produces the |
| 503 | /// same pixels for a fraction of the cost. Detail is a function of what's |
| 504 | /// resolvable at the current zoom, never of whether the user is scrolling: |
| 505 | /// this replaces the old `lightScroll` mode, which shed thumbnails and |
| 506 | /// labels mid-pan (visibly) yet saved almost nothing — the real per-clip |
| 507 | /// cost was the path/clip-state chrome that ran for sub-pixel clips. |
| 508 | private static let lodMinWidth: CGFloat = 3 |
| 509 | |
| 510 | // MARK: - Scene tile cache |
| 511 | // |
| 512 | // The scene (lane clips: bodies, filmstrips, waveforms, strips, labels, |
| 513 | // badges, selection tint) is rasterized into per-lane, 512-pt-wide tiles in |
| 514 | // TIMELINE space — a tile's x axis is (t − sliceStart)·pxPerSecond, which |
| 515 | // is independent of the pan origin. Panning and the 60 Hz playhead redraw |
| 516 | // therefore cost a handful of blits plus the (cheap) chrome, instead of |
| 517 | // re-rendering thousands of clips. Anything that changes scene pixels — |
| 518 | // model edits, selection, thumbnails landing, theme, zoom, lane heights — |
| 519 | // flushes; tiles rebuild lazily, visible-first, at direct-render cost. |
| 520 | // Active edit gestures bypass tiles entirely (direct draw), so a drag never |
| 521 | // thrashes the cache mid-gesture. |
| 522 | |
| 523 | private struct TileKey: Hashable { |
| 524 | let ref: TrackRef |
| 525 | let slice: Int |
| 526 | } |
| 527 | /// Everything a tile's pixels depend on besides the model/selection (those |
| 528 | /// flush via notifications). A mismatch flushes the whole cache. |
| 529 | private struct TileParams: Equatable { |
| 530 | var pps: Double |
| 531 | var scale: CGFloat |
| 532 | var light: Bool |
| 533 | var heights: [TrackRef: CGFloat] |
| 534 | var showFilmstrips: Bool |
| 535 | } |
| 536 | private var tiles: [TileKey: CGImage] = [:] |
| 537 | private var tileUse: [TileKey: Int] = [:] |
| 538 | private var tileTick = 0 |
| 539 | private var tileParams: TileParams? |
| 540 | private static let tileW: CGFloat = 512 |
| 541 | /// ~96 tiles ≈ a few viewports of 2× lane strips (~100 MB worst case). |
| 542 | private static let maxTiles = 96 |
| 543 | /// Cap on tile rasterizations per frame. A cold viewport (fresh zoom, big |
| 544 | /// jump) draws the un-cached slices directly this frame — same pixels, |
| 545 | /// direct cost — and fills the cache over the next few frames instead of |
| 546 | /// paying ~25 bitmap allocations in one frame. |
| 547 | private static let tileRendersPerFrame = 6 |
| 548 | private var tileRenderBudget = 0 |
| 549 | private var tileFillPending = false |
| 550 | private var lastDrawOrigin = 0.0 |
| 551 | private var lastDrawPps = 0.0 |
| 552 | /// Escape hatch for A/B measurement and debugging: SEQ_NOTILES=1 forces |
| 553 | /// the direct (Stage-1) render path. Mutable so --perftest can A/B both |
| 554 | /// paths inside one process (immune to thermal drift between runs). |
| 555 | static var tilesDisabled = |
| 556 | ProcessInfo.processInfo.environment["SEQ_NOTILES"] == "1" |
| 557 | |
| 558 | private func flushTiles() { |
| 559 | tiles.removeAll(keepingCapacity: true) |
| 560 | tileUse.removeAll(keepingCapacity: true) |
| 561 | } |
| 562 | |
| 563 | /// Device-pixel scale of the surface actually being drawn into, captured |
| 564 | /// from the context CTM at the top of `draw`. Falling back to the window's |
| 565 | /// backing scale is only right when they agree — an offscreen 1× target |
| 566 | /// (like --perftest's bitmap) on a 2× machine would otherwise get 2× tiles |
| 567 | /// downsampled on every blit. |
| 568 | private var renderScale: CGFloat = 2 |
| 569 | |
| 570 | private var backingScale: CGFloat { |
| 571 | window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 |
| 572 | } |
| 573 | |
| 574 | /// Quantize a length/offset to the device-pixel grid. Tiles are blitted at |
| 575 | /// integral device pixels; quantizing the shared geometry (origin, scroll, |
| 576 | /// lane heights) keeps the tile and direct paths on the same sub-pixel |
| 577 | /// phase, so their output is pixel-identical and blits never resample. |
| 578 | private func quantized(_ v: CGFloat) -> CGFloat { |
| 579 | (v * renderScale).rounded() / renderScale |
| 580 | } |
| 581 | |
| 582 | private func evictTilesIfNeeded() { |
| 583 | guard tiles.count > Self.maxTiles else { return } |
| 584 | for (key, _) in tileUse.sorted(by: { $0.value < $1.value }) |
| 585 | .prefix(tiles.count - Self.maxTiles) { |
| 586 | tiles[key] = nil |
| 587 | tileUse[key] = nil |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | /// Blit the lane's visible tiles. Missing tiles are rendered up to the |
| 592 | /// per-frame budget; past it, their span draws directly (identical pixels) |
| 593 | /// and a follow-up display pass finishes filling the cache. |
| 594 | private func blitLaneTiles(ref: TrackRef, lane: NSRect) { |
| 595 | guard let cg = NSGraphicsContext.current?.cgContext else { return } |
| 596 | let sliceDur = Double(Self.tileW) / pxPerSecond |
| 597 | let leftSec = secondsFor(headerW) |
| 598 | let rightSec = secondsFor(bounds.width) |
| 599 | var slice = Int(floor(leftSec / sliceDur)) |
| 600 | let last = Int(floor(rightSec / sliceDur)) |
| 601 | while slice <= last { |
| 602 | let x = xFor(Double(slice) * sliceDur) |
| 603 | if let img = tileImage(ref: ref, lane: lane, slice: slice, sliceDur: sliceDur) { |
| 604 | // CGContext blit (NSImage.draw pays rep-matching/colorspace |
| 605 | // overhead per call). Local flip: CGImages draw bottom-up. |
| 606 | cg.saveGState() |
| 607 | cg.translateBy(x: x, y: lane.maxY) |
| 608 | cg.scaleBy(x: 1, y: -1) |
| 609 | cg.draw(img, in: CGRect(x: 0, y: 0, width: Self.tileW, |
| 610 | height: lane.height)) |
| 611 | cg.restoreGState() |
| 612 | if DrawProf.on { DrawProf.tileBlits += 1 } |
| 613 | } else { |
| 614 | // Over budget this frame: draw the slice's span directly. |
| 615 | NSGraphicsContext.current?.saveGraphicsState() |
| 616 | NSRect(x: x, y: lane.minY, width: Self.tileW, |
| 617 | height: lane.height).clip() |
| 618 | drawLaneClips(ref: ref, lane: lane, cullX0: x, cullX1: x + Self.tileW) |
| 619 | NSGraphicsContext.current?.restoreGraphicsState() |
| 620 | scheduleTileFill() |
| 621 | } |
| 622 | slice += 1 |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | /// One coalesced follow-up display pass to keep rasterizing missed tiles |
| 627 | /// after a budget-limited frame. |
| 628 | private func scheduleTileFill() { |
| 629 | guard !tileFillPending else { return } |
| 630 | tileFillPending = true |
| 631 | DispatchQueue.main.async { [weak self] in |
| 632 | guard let self else { return } |
| 633 | self.tileFillPending = false |
| 634 | self.needsDisplay = true |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /// The cached tile for (lane, slice), rendering it via the SAME |
| 639 | /// `drawLaneClips` code the direct path uses — pixel-equivalent by |
| 640 | /// construction. The context is translated so the slice's start lands at |
| 641 | /// x = 0 and the lane's top at y = 0; both offsets cancel the pan origin, |
| 642 | /// which is what makes the raster reusable across scroll positions. |
| 643 | private func tileImage(ref: TrackRef, lane: NSRect, slice: Int, |
| 644 | sliceDur: Double) -> CGImage? { |
| 645 | let key = TileKey(ref: ref, slice: slice) |
| 646 | tileTick += 1 |
| 647 | tileUse[key] = tileTick |
| 648 | if let img = tiles[key] { return img } |
| 649 | guard tileRenderBudget > 0 else { return nil } |
| 650 | tileRenderBudget -= 1 |
| 651 | |
| 652 | let scale = renderScale |
| 653 | let pxW = Int((Self.tileW * scale).rounded()) |
| 654 | let pxH = Int((lane.height * scale).rounded()) |
| 655 | // Native Quartz raster format (BGRA premultiplied, little-endian) in |
| 656 | // the window's own colorspace: blits are then straight memory copies. |
| 657 | // An NSBitmapImageRep here (RGBA, deviceRGB) costs a per-blit swizzle |
| 658 | // + colorspace conversion — ~1.5 ms per tile, wiping out the caching. |
| 659 | let space = window?.colorSpace?.cgColorSpace |
| 660 | ?? CGColorSpace(name: CGColorSpace.sRGB)! |
| 661 | guard pxW > 0, pxH > 0, |
| 662 | let cg = CGContext( |
| 663 | data: nil, width: pxW, height: pxH, bitsPerComponent: 8, |
| 664 | bytesPerRow: 0, space: space, |
| 665 | bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue |
| 666 | | CGBitmapInfo.byteOrder32Little.rawValue) else { return nil } |
| 667 | |
| 668 | NSGraphicsContext.saveGraphicsState() |
| 669 | // Map: device pixels → points, unflipped → flipped (the view draws |
| 670 | // top-down), view space → tile space. Wrapped in a `flipped: true` |
| 671 | // NSGraphicsContext so text and images orient correctly. |
| 672 | cg.scaleBy(x: scale, y: scale) |
| 673 | cg.translateBy(x: 0, y: lane.height) |
| 674 | cg.scaleBy(x: 1, y: -1) |
| 675 | let x0 = xFor(Double(slice) * sliceDur) |
| 676 | cg.translateBy(x: -x0, y: -lane.minY) |
| 677 | NSGraphicsContext.current = NSGraphicsContext(cgContext: cg, flipped: true) |
| 678 | drawLaneClips(ref: ref, lane: lane, cullX0: x0, cullX1: x0 + Self.tileW) |
| 679 | NSGraphicsContext.restoreGraphicsState() |
| 680 | |
| 681 | guard let img = cg.makeImage() else { return nil } |
| 682 | tiles[key] = img |
| 683 | evictTilesIfNeeded() |
| 684 | if DrawProf.on { DrawProf.tileRenders += 1 } |
| 685 | return img |
| 686 | } |
| 687 | |
| 688 | /// Live draw-time telemetry, on when the app is launched with |
| 689 | /// SEQ_DRAWPROF=1 in the environment (run the binary directly with stderr |
| 690 | /// to a file — NSLog is invisible under `open`). Prints one line per |
| 691 | /// second: draws that second and mean/max ms per draw. Purely additive so |
| 692 | /// GUI perf work can be judged against real numbers, like --perftest. |
| 693 | private static let liveProf = ProcessInfo.processInfo.environment["SEQ_DRAWPROF"] == "1" |
| 694 | private var profWindowStart = CACurrentMediaTime() |
| 695 | private var profFrames = 0 |
| 696 | private var profTotalMs = 0.0 |
| 697 | private var profMaxMs = 0.0 |
| 698 | |
| 699 | override func draw(_ dirtyRect: NSRect) { |
| 700 | let profT0 = Self.liveProf ? CACurrentMediaTime() : 0 |
| 701 | defer { |
| 702 | if Self.liveProf { |
| 703 | let ms = (CACurrentMediaTime() - profT0) * 1000 |
| 704 | profFrames += 1 |
| 705 | profTotalMs += ms |
| 706 | profMaxMs = max(profMaxMs, ms) |
| 707 | let now = CACurrentMediaTime() |
| 708 | if now - profWindowStart >= 1.0 { |
| 709 | FileHandle.standardError.write(Data(String(format: |
| 710 | "[drawprof] %d draws, avg %.2f ms, max %.2f ms\n", |
| 711 | profFrames, profTotalMs / Double(profFrames), |
| 712 | profMaxMs).utf8)) |
| 713 | profWindowStart = now |
| 714 | profFrames = 0 |
| 715 | profTotalMs = 0 |
| 716 | profMaxMs = 0 |
| 717 | } |
| 718 | } |
| 719 | } |
| 720 | Theme.timelineBg.setFill() |
| 721 | bounds.fill() |
| 722 | |
| 723 | // Publish the on-screen time span — a heat anchor for the proxy |
| 724 | // builder (the user scrubs inside what they can see). Cheap enough |
| 725 | // to set every frame; read lazily by the chunk scheduler. |
| 726 | let visibleSpan = Double(bounds.width - headerW) / pxPerSecond |
| 727 | ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan)) |
| 728 | |
| 729 | DrawProf.t("scene") { rebuildSceneIfNeeded() } |
| 730 | DrawProf.t("cull") { |
| 731 | // Match the tile raster scale to the surface being drawn into. |
| 732 | if let ctm = NSGraphicsContext.current?.cgContext |
| 733 | .userSpaceToDeviceSpaceTransform { |
| 734 | let s = abs(ctm.a) |
| 735 | if s > 0.1, s != renderScale { |
| 736 | renderScale = s |
| 737 | flushTiles() |
| 738 | } |
| 739 | } |
| 740 | scrollY = quantized(min(scrollY, maxScrollY)) |
| 741 | // Snap the pan origin to the device-pixel grid (≤ half a device |
| 742 | // pixel, imperceptible) so tile blits land on integral pixels and |
| 743 | // the tile and direct paths share one sub-pixel phase. |
| 744 | let q = pxPerSecond * Double(renderScale) |
| 745 | originSecond = (originSecond * q).rounded() / q |
| 746 | } |
| 747 | // Route the frame: warm frames (rest, pan, playback) blit the tile |
| 748 | // cache; cold frames — a zoom in flight (every tile invalid), a |
| 749 | // teleport jump (a whole viewport of new slices), or a live edit |
| 750 | // gesture (model changes per event) — rasterize all lanes in parallel |
| 751 | // instead, and the cache refills once things settle. |
| 752 | let editing = store.gestureBaseModel != nil || drag.mode == .box |
| 753 | let jumped = abs(originSecond - lastDrawOrigin) * pxPerSecond |
| 754 | > Double(Self.tileW) |
| 755 | let zoomed = pxPerSecond != lastDrawPps |
| 756 | lastDrawOrigin = originSecond |
| 757 | lastDrawPps = pxPerSecond |
| 758 | let tiled = !Self.tilesDisabled && !editing && !zoomed && !jumped |
| 759 | tileRenderBudget = Self.tileRendersPerFrame |
| 760 | if tiled { |
| 761 | let params = TileParams( |
| 762 | pps: pxPerSecond, scale: renderScale, light: Theme.light, |
| 763 | heights: Dictionary(uniqueKeysWithValues: |
| 764 | laneRows.map { ($0, laneHeight($0)) }), |
| 765 | showFilmstrips: session.showFilmstrips) |
| 766 | if params != tileParams { |
| 767 | flushTiles() |
| 768 | tileParams = params |
| 769 | } |
| 770 | } else if zoomed || jumped { |
| 771 | // The gesture invalidated the cache; warm it back up as soon as |
| 772 | // the stream of cold frames stops. |
| 773 | flushTiles() |
| 774 | tileParams = nil |
| 775 | scheduleTileFill() |
| 776 | } |
| 777 | let rows = laneRows |
| 778 | DrawProf.t("lanes") { |
| 779 | if tiled { |
| 780 | for row in 0..<rows.count { |
| 781 | drawLane(row: row, ref: rows[row], tiled: true) |
| 782 | } |
| 783 | } else { |
| 784 | drawLanesDirectParallel(rows: rows) |
| 785 | } |
| 786 | } |
| 787 | DrawProf.t("hints") { |
| 788 | drawDragHintLanes() |
| 789 | drawFileDropPreview() |
| 790 | } |
| 791 | DrawProf.t("fusion") { drawFusionBand() } |
| 792 | // Opaque header column so clips never show behind the buttons. |
| 793 | Theme.timelineBg.setFill() |
| 794 | NSRect(x: 0, y: rulerH, width: headerW, height: bounds.height - rulerH).fill() |
| 795 | DrawProf.t("headers") { |
| 796 | drawFusionHeader() |
| 797 | for row in 0..<rows.count { |
| 798 | drawTrackHeader(row: row, ref: rows[row], lane: laneRect(row: row)) |
| 799 | } |
| 800 | } |
| 801 | DrawProf.t("ruler") { drawRuler() } |
| 802 | DrawProf.t("strip") { drawOptimizeStrip() } |
| 803 | DrawProf.t("overlays") { |
| 804 | drawInOut() |
| 805 | drawMarkers() |
| 806 | drawSnapIndicator() |
| 807 | drawBoxSelect() |
| 808 | drawPlayhead() |
| 809 | } |
| 810 | DrawProf.t("scrollbars") { drawScrollbars() } |
| 811 | } |
| 812 | |
| 813 | // MARK: - Scroll/zoom bars |
| 814 | // Floating bars: drag the middle to pan; drag either END to zoom — |
| 815 | // resizing the thumb literally resizes the viewport. Horizontal = time |
| 816 | // zoom, vertical = track height. |
| 817 | |
| 818 | private let sbThick: CGFloat = 9 |
| 819 | |
| 820 | private var hBarRect: NSRect { |
| 821 | NSRect(x: headerW + 2, y: bounds.height - sbThick - 3, |
| 822 | width: max(10, bounds.width - headerW - sbThick - 10), height: sbThick) |
| 823 | } |
| 824 | private var vBarRect: NSRect { |
| 825 | NSRect(x: bounds.width - sbThick - 3, y: lanesTop + 2, |
| 826 | width: sbThick, height: max(10, bounds.height - lanesTop - sbThick - 10)) |
| 827 | } |
| 828 | |
| 829 | private func hDomain() -> (lo: Double, hi: Double) { |
| 830 | let viewSec = Double(bounds.width - headerW) / pxPerSecond |
| 831 | let lo = min(0, originSecond) |
| 832 | rebuildSceneIfNeeded() |
| 833 | let hi = max(cachedTimelineDuration + 10, originSecond + viewSec) |
| 834 | return (lo, hi) |
| 835 | } |
| 836 | |
| 837 | private func hThumbRect() -> NSRect { |
| 838 | let bar = hBarRect |
| 839 | let (lo, hi) = hDomain() |
| 840 | let viewSec = Double(bounds.width - headerW) / pxPerSecond |
| 841 | let span = max(0.001, hi - lo) |
| 842 | let x0 = bar.minX + CGFloat((originSecond - lo) / span) * bar.width |
| 843 | let w = min(bar.width, max(28, CGFloat(viewSec / span) * bar.width)) |
| 844 | return NSRect(x: min(max(bar.minX, x0), bar.maxX - w), y: bar.minY, |
| 845 | width: w, height: bar.height) |
| 846 | } |
| 847 | |
| 848 | private func vThumbRect() -> NSRect { |
| 849 | let bar = vBarRect |
| 850 | let contentH = max(lanesContentHeight, 1) |
| 851 | let viewH = max(1, bounds.height - lanesTop) |
| 852 | let f = min(1, viewH / contentH) |
| 853 | let y0 = bar.minY + (scrollY / contentH) * bar.height |
| 854 | let h = min(bar.height, max(24, f * bar.height)) |
| 855 | return NSRect(x: bar.minX, y: min(max(bar.minY, y0), bar.maxY - h), |
| 856 | width: bar.width, height: h) |
| 857 | } |
| 858 | |
| 859 | private func drawScrollbars() { |
| 860 | for (bar, thumb) in [(hBarRect, hThumbRect()), (vBarRect, vThumbRect())] { |
| 861 | guard bar.width > 20, bar.height > 4 else { continue } |
| 862 | Theme.label.withAlphaComponent(0.06).setFill() |
| 863 | NSBezierPath(roundedRect: bar, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() |
| 864 | Theme.label.withAlphaComponent(0.25).setFill() |
| 865 | NSBezierPath(roundedRect: thumb, xRadius: sbThick / 2, yRadius: sbThick / 2).fill() |
| 866 | // End grips (the zoom handles) |
| 867 | Theme.label.withAlphaComponent(0.55).setFill() |
| 868 | if bar.width > bar.height { |
| 869 | NSBezierPath(ovalIn: NSRect(x: thumb.minX + 2.5, y: thumb.midY - 2, |
| 870 | width: 4, height: 4)).fill() |
| 871 | NSBezierPath(ovalIn: NSRect(x: thumb.maxX - 6.5, y: thumb.midY - 2, |
| 872 | width: 4, height: 4)).fill() |
| 873 | } else { |
| 874 | NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.minY + 2.5, |
| 875 | width: 4, height: 4)).fill() |
| 876 | NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.maxY - 6.5, |
| 877 | width: 4, height: 4)).fill() |
| 878 | } |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | private func scrollbarHit(_ p: NSPoint) -> DragMode? { |
| 883 | let hT = hThumbRect(), vT = vThumbRect() |
| 884 | if hBarRect.insetBy(dx: 0, dy: -3).contains(p) { |
| 885 | if abs(p.x - hT.minX) < 8 { return .hBarLeft } |
| 886 | if abs(p.x - hT.maxX) < 8 { return .hBarRight } |
| 887 | return .hBarPan |
| 888 | } |
| 889 | if vBarRect.insetBy(dx: -3, dy: 0).contains(p) { |
| 890 | if abs(p.y - vT.minY) < 8 { return .vBarTop } |
| 891 | if abs(p.y - vT.maxY) < 8 { return .vBarBottom } |
| 892 | return .vBarPan |
| 893 | } |
| 894 | return nil |
| 895 | } |
| 896 | |
| 897 | private func drawLane(row: Int, ref: TrackRef, tiled: Bool) { |
| 898 | let lane = laneRect(row: row) |
| 899 | guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return } |
| 900 | (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill() |
| 901 | NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill() |
| 902 | |
| 903 | if tiled { |
| 904 | blitLaneTiles(ref: ref, lane: lane) |
| 905 | } else { |
| 906 | drawLaneClips(ref: ref, lane: lane, |
| 907 | cullX0: headerW, cullX1: bounds.width) |
| 908 | } |
| 909 | drawLaneDecorations(ref: ref, lane: lane) |
| 910 | } |
| 911 | |
| 912 | /// Pinned labels + overlap bands — main-thread, over blits or direct clips. |
| 913 | private func drawLaneDecorations(ref: TrackRef, lane: NSRect) { |
| 914 | drawPinnedLabels(ref: ref, lane: lane) |
| 915 | |
| 916 | // Bright red overlap ranges on top of the clip bodies. |
| 917 | for o in overlapsByLane[ref] ?? [] { |
| 918 | let r = NSRect(x: xFor(o.start), y: lane.minY + 1, |
| 919 | width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height - 2) |
| 920 | guard r.maxX > headerW, r.minX < bounds.width else { continue } |
| 921 | NSColor.systemRed.withAlphaComponent(0.40).setFill() |
| 922 | r.fill() |
| 923 | NSColor.systemRed.setStroke() |
| 924 | let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5)) |
| 925 | p.lineWidth = 1.5 |
| 926 | p.stroke() |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | // MARK: - Parallel direct rendering (cold frames) |
| 931 | |
| 932 | /// Persistent per-lane raster buffers for cold frames; recreated on size/ |
| 933 | /// scale change. ~7 MB per lane at 2× on a wide window — the price of |
| 934 | /// rasterizing a zoom gesture on every core instead of one. |
| 935 | private var laneBufs: [TrackRef: CGContext] = [:] |
| 936 | |
| 937 | /// A cold frame (zoom in flight, teleport jump, live edit gesture) must |
| 938 | /// re-render every visible lane from scratch — but lanes are independent, |
| 939 | /// so rasterize them CONCURRENTLY into per-lane buffers with the exact |
| 940 | /// same `drawLaneClips` code and composite on main. Same rasterizer, same |
| 941 | /// pixels; wall-clock is the slowest lane, not the sum. The storyboard |
| 942 | /// lane draws serially on main (BoardStore's raster cache isn't locked). |
| 943 | private func drawLanesDirectParallel(rows: [TrackRef]) { |
| 944 | var work: [(ref: TrackRef, lane: NSRect)] = [] |
| 945 | for (row, ref) in rows.enumerated() { |
| 946 | let lane = laneRect(row: row) |
| 947 | guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue } |
| 948 | (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill() |
| 949 | NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill() |
| 950 | work.append((ref, lane)) |
| 951 | } |
| 952 | guard !work.isEmpty, let mainCG = NSGraphicsContext.current?.cgContext |
| 953 | else { return } |
| 954 | |
| 955 | let scale = renderScale |
| 956 | let pxW = Int((bounds.width * scale).rounded()) |
| 957 | let space = window?.colorSpace?.cgColorSpace |
| 958 | ?? CGColorSpace(name: CGColorSpace.sRGB)! |
| 959 | let ctxs: [CGContext?] = work.map { item in |
| 960 | if item.ref == .storyboard { return nil } |
| 961 | let pxH = Int((item.lane.height * scale).rounded()) |
| 962 | if let c = laneBufs[item.ref], c.width == pxW, c.height == pxH { |
| 963 | return c |
| 964 | } |
| 965 | let c = CGContext( |
| 966 | data: nil, width: pxW, height: pxH, bitsPerComponent: 8, |
| 967 | bytesPerRow: 0, space: space, |
| 968 | bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue |
| 969 | | CGBitmapInfo.byteOrder32Little.rawValue) |
| 970 | laneBufs[item.ref] = c |
| 971 | return c |
| 972 | } |
| 973 | |
| 974 | DispatchQueue.concurrentPerform(iterations: work.count) { i in |
| 975 | guard let cg = ctxs[i] else { return } // storyboard → main below |
| 976 | let (ref, lane) = work[i] |
| 977 | autoreleasepool { |
| 978 | cg.clear(CGRect(x: 0, y: 0, width: CGFloat(cg.width), |
| 979 | height: CGFloat(cg.height))) |
| 980 | cg.saveGState() |
| 981 | cg.scaleBy(x: scale, y: scale) |
| 982 | cg.translateBy(x: 0, y: lane.height) |
| 983 | cg.scaleBy(x: 1, y: -1) |
| 984 | cg.translateBy(x: 0, y: -lane.minY) |
| 985 | // NSGraphicsContext.current is thread-local — but |
| 986 | // concurrentPerform runs one of these iterations ON the |
| 987 | // calling (main) thread, so the view's own context must be |
| 988 | // restored, not nilled. |
| 989 | let prev = NSGraphicsContext.current |
| 990 | NSGraphicsContext.current = NSGraphicsContext(cgContext: cg, flipped: true) |
| 991 | drawLaneClips(ref: ref, lane: lane, |
| 992 | cullX0: headerW, cullX1: bounds.width) |
| 993 | NSGraphicsContext.current = prev |
| 994 | cg.restoreGState() |
| 995 | } |
| 996 | } |
| 997 | |
| 998 | for (i, item) in work.enumerated() { |
| 999 | if let cg = ctxs[i] { |
| 1000 | if let img = cg.makeImage() { |
| 1001 | mainCG.saveGState() |
| 1002 | mainCG.translateBy(x: 0, y: item.lane.maxY) |
| 1003 | mainCG.scaleBy(x: 1, y: -1) |
| 1004 | mainCG.draw(img, in: CGRect(x: 0, y: 0, width: bounds.width, |
| 1005 | height: item.lane.height)) |
| 1006 | mainCG.restoreGState() |
| 1007 | } |
| 1008 | } else { |
| 1009 | drawLaneClips(ref: item.ref, lane: item.lane, |
| 1010 | cullX0: headerW, cullX1: bounds.width) |
| 1011 | } |
| 1012 | drawLaneDecorations(ref: item.ref, lane: item.lane) |
| 1013 | } |
| 1014 | } |
| 1015 | |
| 1016 | /// One lane's clips into the current context, culled and clamped to the |
| 1017 | /// window [cullX0, cullX1] (view coordinates). The window is the view for |
| 1018 | /// direct drawing and the tile's span (± bleed) when rendering a tile — |
| 1019 | /// the drawing itself is identical, so the two paths are pixel-equivalent. |
| 1020 | private func drawLaneClips(ref: TrackRef, lane: NSRect, |
| 1021 | cullX0: CGFloat, cullX1: CGFloat) { |
| 1022 | let overlappingIds = overlapIdsByLane[ref] ?? [] |
| 1023 | // Bleed: fade handles / shot dividers / borders paint a few points |
| 1024 | // past a clip's rect, so a clip just outside the window still owns |
| 1025 | // pixels inside it. |
| 1026 | let bleed: CGFloat = 8 |
| 1027 | let leftSec = secondsFor(cullX0 - bleed) |
| 1028 | let rightSec = secondsFor(cullX1 + bleed) |
| 1029 | let colors = laneColors(ref) |
| 1030 | let clips = clipsByLane[ref] ?? [] |
| 1031 | // A storyboard panel's left divider depends on whether an earlier panel |
| 1032 | // exists on the lane; the array is start-sorted, so any predecessor |
| 1033 | // with a strictly smaller start means "has previous". |
| 1034 | let range = visibleIndexRange(clips, lane: ref, left: leftSec, right: rightSec) |
| 1035 | for i in range { |
| 1036 | let clip = clips[i] |
| 1037 | if clip.end < leftSec { continue } |
| 1038 | let hasPrevPanel = ref == .storyboard && i > 0 |
| 1039 | && clips[0].start < clip.start - 1e-6 |
| 1040 | drawClip(clip, lane: lane, ref: ref, colors: colors, |
| 1041 | overlapping: overlappingIds.contains(clip.id), |
| 1042 | hasPrevPanel: hasPrevPanel, |
| 1043 | cullX0: cullX0 - bleed, cullX1: cullX1 + bleed) |
| 1044 | } |
| 1045 | } |
| 1046 | |
| 1047 | /// Circular SF-Symbol button in the header column (hide preview / focus). |
| 1048 | private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) { |
| 1049 | drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5, |
| 1050 | width: 17, height: 17), on: on) |
| 1051 | } |
| 1052 | |
| 1053 | private func drawHeaderButton(_ symbolName: String, in r: NSRect, on: Bool) { |
| 1054 | (on ? NSColor.white : NSColor.black.withAlphaComponent(0.35)).setFill() |
| 1055 | NSBezierPath(ovalIn: r).fill() |
| 1056 | let tint = on ? NSColor.black : NSColor(calibratedWhite: 0.95, alpha: 0.9) |
| 1057 | guard let img = Self.bakedSymbol(symbolName, pointSize: 9, weight: .bold, |
| 1058 | tint: tint, key: "hdr.\(symbolName).\(on)") else { return } |
| 1059 | let s = img.size |
| 1060 | img.draw(in: NSRect(x: r.midX - s.width / 2, y: r.midY - s.height / 2, |
| 1061 | width: s.width, height: s.height), |
| 1062 | from: .zero, operation: .sourceOver, fraction: 1) |
| 1063 | } |
| 1064 | |
| 1065 | /// A vivid swatch of the track's own colour — opens the picker on click. |
| 1066 | private func drawHeaderColorSwatch(in r: NSRect, hue: Double) { |
| 1067 | NSColor(calibratedHue: hue, saturation: 0.85, brightness: 1, alpha: 1).setFill() |
| 1068 | NSBezierPath(ovalIn: r).fill() |
| 1069 | NSColor.white.withAlphaComponent(0.95).setStroke() |
| 1070 | let ring = NSBezierPath(ovalIn: r.insetBy(dx: 0.75, dy: 0.75)) |
| 1071 | ring.lineWidth = 1.5 |
| 1072 | ring.stroke() |
| 1073 | NSColor.black.withAlphaComponent(0.5).setStroke() |
| 1074 | let outer = NSBezierPath(ovalIn: r.insetBy(dx: -0.25, dy: -0.25)) |
| 1075 | outer.lineWidth = 0.75 |
| 1076 | outer.stroke() |
| 1077 | } |
| 1078 | |
| 1079 | /// The hide / focus (and, on tall enough lanes, colour) button rects for a |
| 1080 | /// header lane. One source of truth for drawing AND hit-testing. `color` is |
| 1081 | /// nil when the lane is too short to also fit the swatch. |
| 1082 | func headerButtonRects(lane: NSRect) -> (hide: NSRect, focus: NSRect, color: NSRect?) { |
| 1083 | func rect(_ cy: CGFloat, _ d: CGFloat = 17) -> NSRect { |
| 1084 | NSRect(x: headerW / 2 - d / 2, y: cy - d / 2, width: d, height: d) |
| 1085 | } |
| 1086 | if lane.height >= Self.headerColorMinHeight { |
| 1087 | return (rect(lane.minY + lane.height * 0.22), |
| 1088 | rect(lane.minY + lane.height * 0.50), |
| 1089 | rect(lane.minY + lane.height * 0.78, 15)) |
| 1090 | } |
| 1091 | return (rect(lane.minY + lane.height * 0.28), |
| 1092 | rect(lane.minY + lane.height * 0.72), nil) |
| 1093 | } |
| 1094 | |
| 1095 | private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) { |
| 1096 | // Header column: track color strip with hide (eye.slash), focus |
| 1097 | // (expand), and — when there's room — a colour swatch for the picker. |
| 1098 | let hidden = session.hiddenTracks.contains(ref) |
| 1099 | let focused = session.focusedTracks.contains(ref) |
| 1100 | let strip = NSRect(x: 0, y: lane.minY, width: headerW, height: lane.height) |
| 1101 | let color = hidden ? NSColor(calibratedWhite: 0.35, alpha: 1) : trackColor(ref) |
| 1102 | color.setFill() |
| 1103 | NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() |
| 1104 | // Too short a lane can't fit the buttons without crowding — just show |
| 1105 | // the colour strip (hide/focus/reset stay on the right-click menu). |
| 1106 | guard lane.height >= Self.headerButtonsMinHeight else { return } |
| 1107 | let rects = headerButtonRects(lane: lane) |
| 1108 | drawHeaderButton(UI.hideSymbol, in: rects.hide, on: hidden) |
| 1109 | drawHeaderButton(UI.focusSymbol, in: rects.focus, on: focused) |
| 1110 | // The storyboard lane's hue is fixed — no colour picker for it. |
| 1111 | if let c = rects.color, ref != .storyboard { |
| 1112 | drawHeaderColorSwatch(in: c, hue: project.hue(for: ref)) |
| 1113 | } |
| 1114 | } |
| 1115 | |
| 1116 | static let headerButtonsMinHeight: CGFloat = 44 |
| 1117 | /// Above this lane height the header also shows the colour swatch. |
| 1118 | static let headerColorMinHeight: CGFloat = 62 |
| 1119 | |
| 1120 | // The header swatch has no NSView to anchor the picker to, so drop an |
| 1121 | // invisible one over it for the duration (reuses the picker's positioning |
| 1122 | // + hover-corridor logic); it's removed when the picker closes. |
| 1123 | private var headerColorAnchor: NSView? |
| 1124 | private var headerColorSnapshot: ProjectModel? |
| 1125 | |
| 1126 | private func openHeaderColorPicker(videoIndex: Int, swatchRect: NSRect) { |
| 1127 | headerColorAnchor?.removeFromSuperview() |
| 1128 | let anchor = NSView(frame: swatchRect) |
| 1129 | addSubview(anchor) |
| 1130 | headerColorAnchor = anchor |
| 1131 | let hue = project.hue(for: .video(videoIndex)) |
| 1132 | let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1) |
| 1133 | // Live, non-undoable preview; commit as ONE undo step on close. No held |
| 1134 | // gesture, so editing the timeline mid-pick can't trip anything. |
| 1135 | headerColorSnapshot = store.project |
| 1136 | ColorPickerPanel.show(under: anchor, color: seed, onChange: { [weak self] c in |
| 1137 | guard let self, |
| 1138 | let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return } |
| 1139 | self.store.preview { model in |
| 1140 | if model.tracks.indices.contains(videoIndex) { |
| 1141 | model.tracks[videoIndex].hue = h |
| 1142 | } |
| 1143 | } |
| 1144 | }, onClose: { [weak self] in |
| 1145 | guard let self else { return } |
| 1146 | if let snap = self.headerColorSnapshot { |
| 1147 | self.headerColorSnapshot = nil |
| 1148 | self.store.commitPreview(from: snap) |
| 1149 | } |
| 1150 | self.headerColorAnchor?.removeFromSuperview() |
| 1151 | self.headerColorAnchor = nil |
| 1152 | }) |
| 1153 | } |
| 1154 | |
| 1155 | private func drawFusionHeader() { |
| 1156 | guard fusionBandH > 0 else { return } |
| 1157 | let band = NSRect(x: 0, y: fusionTop, width: headerW, height: fusionBandH) |
| 1158 | FusionComps.yellow.withAlphaComponent(session.fusionHidden ? 0.25 : 0.6).setFill() |
| 1159 | NSBezierPath(roundedRect: band.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill() |
| 1160 | let attrs: [NSAttributedString.Key: Any] = [ |
| 1161 | .font: NSFont.systemFont(ofSize: 9, weight: .bold), |
| 1162 | .foregroundColor: NSColor.black.withAlphaComponent(0.8), |
| 1163 | ] |
| 1164 | let fSize = "F".size(withAttributes: attrs) |
| 1165 | "F".draw(at: NSPoint(x: headerW / 2 - fSize.width / 2, y: band.minY + 1), |
| 1166 | withAttributes: attrs) |
| 1167 | drawHeaderButton(UI.hideSymbol, centerY: band.minY + 19, on: session.fusionHidden) |
| 1168 | drawHeaderButton(UI.focusSymbol, centerY: band.minY + 37, on: session.fusionFocus) |
| 1169 | } |
| 1170 | |
| 1171 | private func drawDragHintLanes() { |
| 1172 | // While dragging a clip below the last lane, hint the rows that would |
| 1173 | // become tracks. No resident placeholder lane. (Dropped FILES get their |
| 1174 | // own landing preview in drawFileDropPreview.) |
| 1175 | var rows: [Int] = [] |
| 1176 | let count = laneRows.count |
| 1177 | if drag.mode == .move, let row = dragHintRow, row >= count { |
| 1178 | rows = Array(count...row) |
| 1179 | } |
| 1180 | for row in rows { |
| 1181 | let lane = laneRect(row: row).insetBy(dx: 2, dy: 2) |
| 1182 | guard lane.minY < bounds.maxY else { continue } |
| 1183 | let path = NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4) |
| 1184 | path.setLineDash([4, 4], count: 2, phase: 0) |
| 1185 | Theme.dragHint.withAlphaComponent(0.6).setStroke() |
| 1186 | path.stroke() |
| 1187 | Theme.dragHint.withAlphaComponent(0.25).setFill() |
| 1188 | path.fill() |
| 1189 | } |
| 1190 | } |
| 1191 | |
| 1192 | private func drawClip(_ clip: Clip, lane: NSRect, ref: TrackRef, colors: LaneColors, |
| 1193 | overlapping: Bool, hasPrevPanel: Bool, |
| 1194 | cullX0: CGFloat, cullX1: CGFloat) { |
| 1195 | let x0 = xFor(clip.start), x1 = xFor(clip.end) |
| 1196 | let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height) |
| 1197 | guard rect.maxX > cullX0, rect.minX < cullX1 else { return } |
| 1198 | let media = clip.mediaId.flatMap { mediaById[$0] } |
| 1199 | let color = colors.base |
| 1200 | let selected = store.selection.contains(clip.id) |
| 1201 | let linkedSel = !selected && linkedSelectionCache.contains(clip.id) |
| 1202 | |
| 1203 | // Storyboard panels tile edge-to-edge (they're gapless) so the track |
| 1204 | // reads as one continuous filmstrip — square corners, no per-panel |
| 1205 | // card, dividers drawn between shots below. |
| 1206 | let storyboard = clip.kind == .storyboard |
| 1207 | |
| 1208 | func bodyFillColor() -> NSColor { |
| 1209 | switch clip.kind { |
| 1210 | case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1) |
| 1211 | case .audio: colors.audioBody |
| 1212 | case .video: media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1) |
| 1213 | : colors.videoBody |
| 1214 | } |
| 1215 | } |
| 1216 | |
| 1217 | // ---- Fast path: a clip too narrow to resolve any detail. ---- |
| 1218 | // Below `lodMinWidth` the rounded corners, strip text, badges and |
| 1219 | // filmstrip/waveform columns are physically invisible, so flat rect |
| 1220 | // fills produce the same picture without the bezier/clip-state chrome |
| 1221 | // that made dense zooms cost hundreds of ms. Everything that still |
| 1222 | // reads at this size is kept: body + strip colors, the audio center |
| 1223 | // line and fade handles (the texture of dense audio lanes), status |
| 1224 | // tint/borders, storyboard dividers. |
| 1225 | if rect.width < Self.lodMinWidth { |
| 1226 | bodyFillColor().setFill() |
| 1227 | rect.fill() |
| 1228 | colors.strip.setFill() |
| 1229 | NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill() |
| 1230 | if clip.kind == .audio { |
| 1231 | color.withAlphaComponent(0.35).setFill() |
| 1232 | NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill() |
| 1233 | // Fade-handle slivers: the full path clips the 7 px handle |
| 1234 | // ovals to the clip body, so at this width only a couple of |
| 1235 | // white columns survive — the speckle texture of a dense audio |
| 1236 | // lane. Fill the same intersection directly. |
| 1237 | (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill() |
| 1238 | let hy = rect.minY + 13.5 |
| 1239 | for x in [xFor(clip.start + clip.fadeIn), xFor(clip.end - clip.fadeOut)] { |
| 1240 | let sliver = NSRect(x: x - 3.5, y: hy, width: 7, height: 7) |
| 1241 | .intersection(rect) |
| 1242 | if !sliver.isEmpty { sliver.fill() } |
| 1243 | } |
| 1244 | } |
| 1245 | // Status tint only: at this width the border stroke's inset path is |
| 1246 | // degenerate (sub-zero width) and renders nothing today, and the |
| 1247 | // red overlap band is painted at lane level. The tint is what reads. |
| 1248 | if selected || linkedSel { |
| 1249 | (selected ? NSColor.controlAccentColor : NSColor.systemCyan) |
| 1250 | .withAlphaComponent(selected ? 0.34 : 0.20).setFill() |
| 1251 | rect.fill() |
| 1252 | } |
| 1253 | if storyboard { drawShotDivider(clip, rect: rect, hasPrev: hasPrevPanel) } |
| 1254 | return |
| 1255 | } |
| 1256 | |
| 1257 | // A zoomed-in clip's rect can be literally millions of points wide; |
| 1258 | // building paths and image draws at that size costs real time even |
| 1259 | // though almost all of it is clipped away. Clamp the card geometry to |
| 1260 | // the view plus a margin that keeps the rounded corners and border |
| 1261 | // strokes of the clamped edges safely offscreen — the pixels inside |
| 1262 | // the view are identical. (The filmstrip keeps the TRUE rect: its |
| 1263 | // thumbnail tiling is phase-anchored to the clip's real left edge, and |
| 1264 | // it already culls to the visible span internally.) |
| 1265 | let clampMargin: CGFloat = 12 |
| 1266 | let cardRect: NSRect = { |
| 1267 | let cx0 = max(rect.minX, cullX0 - clampMargin) |
| 1268 | let cx1 = min(rect.maxX, cullX1 + clampMargin) |
| 1269 | return NSRect(x: cx0, y: rect.minY, width: cx1 - cx0, height: rect.height) |
| 1270 | }() |
| 1271 | |
| 1272 | let bodyRect = storyboard |
| 1273 | ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5, |
| 1274 | width: cardRect.width, height: cardRect.height - 1) |
| 1275 | : cardRect.insetBy(dx: 0.5, dy: 0.5) |
| 1276 | let bodyRadius: CGFloat = storyboard ? 0 : 3 |
| 1277 | let body = DrawProf.t("clip.body") { |
| 1278 | let p = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius, |
| 1279 | yRadius: bodyRadius) |
| 1280 | bodyFillColor().setFill() |
| 1281 | p.fill() |
| 1282 | return p |
| 1283 | } |
| 1284 | |
| 1285 | DrawProf.t("clip.content") { |
| 1286 | NSGraphicsContext.current?.saveGraphicsState() |
| 1287 | body.addClip() |
| 1288 | switch clip.kind { |
| 1289 | case .video: |
| 1290 | if let media, session.showFilmstrips { |
| 1291 | drawFilmstrip(clip, media: media, rect: rect, |
| 1292 | cullX0: cullX0, cullX1: cullX1) |
| 1293 | } |
| 1294 | case .audio: |
| 1295 | if let media { |
| 1296 | drawWaveform(clip, media: media, rect: rect, color: color, |
| 1297 | cullX0: cullX0, cullX1: cullX1) |
| 1298 | } |
| 1299 | drawFades(clip, rect: rect, selected: selected) |
| 1300 | case .storyboard: |
| 1301 | drawBoardThumb(clip, rect: rect) |
| 1302 | } |
| 1303 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1304 | } |
| 1305 | |
| 1306 | // Title strip |
| 1307 | let title = clipTitle(clip, media: media) |
| 1308 | // The label is drawn only when the clip is wide enough to read; a clip |
| 1309 | // narrower than this shows no legible text anyway. |
| 1310 | let showLabel = rect.width >= 22 |
| 1311 | DrawProf.t("clip.strip") { |
| 1312 | NSGraphicsContext.current?.saveGraphicsState() |
| 1313 | body.addClip() |
| 1314 | colors.strip.setFill() |
| 1315 | NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: 13).fill() |
| 1316 | if showLabel { |
| 1317 | // Unpinned: the label sits at the clip's true left edge. A clip |
| 1318 | // scrolled off the left edge gets its label re-pinned to the view |
| 1319 | // edge by `drawPinnedLabels` — an overlay pass, so the pinning |
| 1320 | // never gets baked into a cached tile. |
| 1321 | drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + 1)) |
| 1322 | |
| 1323 | var badgeX = rect.maxX - 16 |
| 1324 | if clip.speed != 1 { |
| 1325 | let s = String(format: "×%.4g", clip.speed) |
| 1326 | let sAttrs: [NSAttributedString.Key: Any] = [ |
| 1327 | .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold), |
| 1328 | .foregroundColor: FusionComps.yellow, |
| 1329 | ] |
| 1330 | let w = s.size(withAttributes: sAttrs).width |
| 1331 | badgeX -= w |
| 1332 | s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs) |
| 1333 | badgeX -= 5 |
| 1334 | } |
| 1335 | if clip.muted, let img = Self.mutedBadge { |
| 1336 | img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), |
| 1337 | from: .zero, operation: .sourceOver, fraction: 0.9) |
| 1338 | badgeX -= 13 |
| 1339 | } |
| 1340 | if clip.linkId != nil, let img = Self.linkBadge { |
| 1341 | img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10), |
| 1342 | from: .zero, operation: .sourceOver, fraction: 0.7) |
| 1343 | } |
| 1344 | } |
| 1345 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1346 | } |
| 1347 | |
| 1348 | DrawProf.t("clip.chrome") { |
| 1349 | // Selection reads as a full-card tint, not just an outline. Link-mates |
| 1350 | // of the selection (they act selected) tint aqua. |
| 1351 | if selected || linkedSel { |
| 1352 | NSGraphicsContext.current?.saveGraphicsState() |
| 1353 | body.addClip() |
| 1354 | (selected ? NSColor.controlAccentColor : NSColor.systemCyan) |
| 1355 | .withAlphaComponent(selected ? 0.34 : 0.20).setFill() |
| 1356 | cardRect.fill() |
| 1357 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1358 | } |
| 1359 | |
| 1360 | // Border LAST, on top of the tint: overlap = red, selection = accent |
| 1361 | // (thick), link-mates = aqua. Storyboard panels skip the per-panel card |
| 1362 | // border — they use the shot dividers below — unless they need a status |
| 1363 | // outline (selected / linked / overlapping). |
| 1364 | if !storyboard || selected || linkedSel || overlapping { |
| 1365 | let radius: CGFloat = storyboard ? 0 : 3 |
| 1366 | let border = NSBezierPath(roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25), |
| 1367 | xRadius: radius, yRadius: radius) |
| 1368 | border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5) |
| 1369 | (selected ? Theme.selection |
| 1370 | : linkedSel ? NSColor.systemCyan |
| 1371 | : overlapping ? NSColor.systemRed : color).setStroke() |
| 1372 | border.stroke() |
| 1373 | } |
| 1374 | |
| 1375 | if storyboard { drawShotDivider(clip, rect: rect, hasPrev: hasPrevPanel) } |
| 1376 | } |
| 1377 | } |
| 1378 | |
| 1379 | private func clipTitle(_ clip: Clip, media: MediaItem?) -> String { |
| 1380 | var title = media?.displayName |
| 1381 | ?? (clip.kind == .storyboard |
| 1382 | ? (panelNamesCache[clip.id] ?? "Panel") : "missing media") |
| 1383 | if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title } |
| 1384 | if clip.kind == .audio { title = "♪ " + title } |
| 1385 | return title |
| 1386 | } |
| 1387 | |
| 1388 | /// Re-pin the title of any clip whose left edge is scrolled offscreen: the |
| 1389 | /// label follows the view edge (as it always has), but the pinning is an |
| 1390 | /// overlay so it is never baked into a scene tile. The patch repaints the |
| 1391 | /// clip's own strip pixels under the new label position (covering the |
| 1392 | /// baked, unpinned label's tail), then restrokes the border segment it |
| 1393 | /// covered — output matches the old single-pass pinned render. |
| 1394 | private func drawPinnedLabels(ref: TrackRef, lane: NSRect) { |
| 1395 | let leftSec = secondsFor(headerW) |
| 1396 | let colors = laneColors(ref) |
| 1397 | let clips = clipsByLane[ref] ?? [] |
| 1398 | // Only clips STRADDLING the left edge qualify; binary search the |
| 1399 | // candidate window instead of scanning every clip left of the view. |
| 1400 | for i in visibleIndexRange(clips, lane: ref, left: leftSec, right: leftSec) { |
| 1401 | let clip = clips[i] |
| 1402 | guard clip.start < leftSec, clip.end > leftSec else { continue } |
| 1403 | let x0 = xFor(clip.start), x1 = xFor(clip.end) |
| 1404 | let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), |
| 1405 | height: lane.height) |
| 1406 | guard rect.width >= 22 else { continue } |
| 1407 | let media = clip.mediaId.flatMap { mediaById[$0] } |
| 1408 | let title = clipTitle(clip, media: media) |
| 1409 | let selected = store.selection.contains(clip.id) |
| 1410 | let linkedSel = !selected && linkedSelectionCache.contains(clip.id) |
| 1411 | let overlapping = (overlapIdsByLane[ref] ?? []).contains(clip.id) |
| 1412 | |
| 1413 | let labelW = title.size(withAttributes: Self.titleAttrs).width |
| 1414 | let patch = NSRect(x: headerW, y: rect.minY, |
| 1415 | width: min(labelW + 12, rect.maxX - headerW), height: 13) |
| 1416 | let storyboard = clip.kind == .storyboard |
| 1417 | let cx1 = min(rect.maxX, bounds.width + 12) |
| 1418 | let cardRect = NSRect(x: max(rect.minX, headerW - 12), y: rect.minY, |
| 1419 | width: cx1 - max(rect.minX, headerW - 12), |
| 1420 | height: rect.height) |
| 1421 | let bodyRect = storyboard |
| 1422 | ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5, |
| 1423 | width: cardRect.width, height: cardRect.height - 1) |
| 1424 | : cardRect.insetBy(dx: 0.5, dy: 0.5) |
| 1425 | let radius: CGFloat = storyboard ? 0 : 3 |
| 1426 | let body = NSBezierPath(roundedRect: bodyRect, xRadius: radius, yRadius: radius) |
| 1427 | |
| 1428 | NSGraphicsContext.current?.saveGraphicsState() |
| 1429 | NSRect(x: patch.minX, y: patch.minY, width: patch.width, |
| 1430 | height: patch.height).clip() |
| 1431 | body.addClip() |
| 1432 | // Rebuild the strip composite: body color under the 0.85-alpha strip. |
| 1433 | switch clip.kind { |
| 1434 | case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1).setFill() |
| 1435 | case .audio: colors.audioBody.setFill() |
| 1436 | case .video: (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1) |
| 1437 | : colors.videoBody).setFill() |
| 1438 | } |
| 1439 | patch.fill() |
| 1440 | colors.strip.setFill() |
| 1441 | patch.fill() |
| 1442 | drawTitle(title, at: NSPoint(x: headerW + 5, y: rect.minY + 1)) |
| 1443 | if selected || linkedSel { |
| 1444 | (selected ? NSColor.controlAccentColor : NSColor.systemCyan) |
| 1445 | .withAlphaComponent(selected ? 0.34 : 0.20).setFill() |
| 1446 | patch.fill() |
| 1447 | } |
| 1448 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1449 | |
| 1450 | // Restroke the border segment the patch painted over — clipped to |
| 1451 | // the patch only (drawClip strokes the border unclipped, so a |
| 1452 | // body clip here would shave its outer antialiasing). |
| 1453 | if !storyboard || selected || linkedSel || overlapping { |
| 1454 | NSGraphicsContext.current?.saveGraphicsState() |
| 1455 | patch.insetBy(dx: 0, dy: -1).clip() |
| 1456 | let border = NSBezierPath( |
| 1457 | roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25), |
| 1458 | xRadius: radius, yRadius: radius) |
| 1459 | border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5) |
| 1460 | (selected ? Theme.selection |
| 1461 | : linkedSel ? NSColor.systemCyan |
| 1462 | : overlapping ? NSColor.systemRed : colors.base).setStroke() |
| 1463 | border.stroke() |
| 1464 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1465 | } |
| 1466 | } |
| 1467 | } |
| 1468 | |
| 1469 | /// Shot divider: an opaque line sitting ON the boundary between two |
| 1470 | /// storyboard panels (they tile gaplessly). A new shot gets a bold |
| 1471 | /// orange bar; frames within a shot get a thin neutral line. The first |
| 1472 | /// panel of the track has no divider on its left. |
| 1473 | private func drawShotDivider(_ clip: Clip, rect: NSRect, hasPrev: Bool) { |
| 1474 | guard hasPrev else { return } |
| 1475 | if clip.newShot { |
| 1476 | NSColor.systemOrange.setFill() |
| 1477 | NSRect(x: rect.minX - 1.5, y: rect.minY, width: 3, height: rect.height).fill() |
| 1478 | } else { |
| 1479 | NSColor(calibratedWhite: Theme.light ? 0.45 : 0.30, alpha: 1).setFill() |
| 1480 | NSRect(x: rect.minX - 0.5, y: rect.minY, width: 1, height: rect.height).fill() |
| 1481 | } |
| 1482 | } |
| 1483 | |
| 1484 | private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect, |
| 1485 | cullX0: CGFloat, cullX1: CGFloat) { |
| 1486 | let thumbH = rect.height - 14 |
| 1487 | guard thumbH > 6 else { return } |
| 1488 | let mediaAspect = media.width > 0 && media.height > 0 |
| 1489 | ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0 |
| 1490 | let thumbW = thumbH * mediaAspect |
| 1491 | // Thumb tiling stays phase-anchored to the clip's TRUE left edge (so |
| 1492 | // thumbnails never slide as the window changes); the window only culls. |
| 1493 | let visX0 = max(rect.minX, cullX0), visX1 = min(rect.maxX, cullX1) |
| 1494 | guard let cg = NSGraphicsContext.current?.cgContext else { return } |
| 1495 | var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW |
| 1496 | while x < visX1 { |
| 1497 | let tlSec = secondsFor(x + thumbW / 2) |
| 1498 | let srcSec = clip.sourceTime(at: tlSec) |
| 1499 | if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) { |
| 1500 | if DrawProf.on { DrawProf.thumbHits += 1 } |
| 1501 | // CGContext blit with a local flip (CGImages draw bottom-up; |
| 1502 | // the view is flipped). The images are pre-converted to the |
| 1503 | // display's format, so this is a plain copy. |
| 1504 | cg.saveGState() |
| 1505 | cg.setAlpha(0.9) |
| 1506 | cg.translateBy(x: x, y: rect.minY + 14 + thumbH) |
| 1507 | cg.scaleBy(x: 1, y: -1) |
| 1508 | cg.draw(img, in: CGRect(x: 0, y: 0, width: thumbW, height: thumbH)) |
| 1509 | cg.restoreGState() |
| 1510 | } |
| 1511 | x += thumbW |
| 1512 | } |
| 1513 | trackColorForClip(clip).withAlphaComponent(0.10).setFill() |
| 1514 | rect.fill() |
| 1515 | } |
| 1516 | |
| 1517 | private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor, |
| 1518 | cullX0: CGFloat, cullX1: CGFloat) { |
| 1519 | // Center line |
| 1520 | color.withAlphaComponent(0.35).setFill() |
| 1521 | NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill() |
| 1522 | guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0 |
| 1523 | else { return } |
| 1524 | // Draw only the visible span — a zoomed-in clip's full rect can be |
| 1525 | // millions of points wide, and image draws at that size are slow even |
| 1526 | // though it's all clipped away. CGImage has no source-rect draw, so |
| 1527 | // map the FULL image through the source→dest transform and clip to |
| 1528 | // the visible span: pixel-identical to a fractional source rect |
| 1529 | // (cropping(to:) would round to whole source pixels), and Quartz only |
| 1530 | // rasterizes the clipped part. |
| 1531 | let visX0 = max(rect.minX, cullX0 - 2), visX1 = min(rect.maxX, cullX1 + 2) |
| 1532 | guard visX1 > visX0, let cg = NSGraphicsContext.current?.cgContext else { return } |
| 1533 | let imgW = CGFloat(img.width) |
| 1534 | let fromX = CGFloat(clip.srcIn / media.duration) * imgW |
| 1535 | let fromW = max(1, CGFloat(clip.duration / media.duration) * imgW) |
| 1536 | let dest = NSRect(x: rect.minX, y: rect.minY + 14, |
| 1537 | width: rect.width, height: rect.height - 16) |
| 1538 | let scaleX = dest.width / fromW |
| 1539 | cg.saveGState() |
| 1540 | cg.setAlpha(0.85) |
| 1541 | cg.clip(to: CGRect(x: visX0, y: dest.minY, width: visX1 - visX0, |
| 1542 | height: dest.height)) |
| 1543 | cg.translateBy(x: dest.minX - fromX * scaleX, y: dest.maxY) |
| 1544 | cg.scaleBy(x: 1, y: -1) |
| 1545 | cg.draw(img, in: CGRect(x: 0, y: 0, width: imgW * scaleX, height: dest.height)) |
| 1546 | cg.restoreGState() |
| 1547 | } |
| 1548 | |
| 1549 | private func drawFades(_ clip: Clip, rect: NSRect, selected: Bool) { |
| 1550 | let top = rect.minY + 13, bottom = rect.maxY |
| 1551 | func fadeShape(from x0: CGFloat, to x1: CGFloat, leading: Bool) { |
| 1552 | guard abs(x1 - x0) > 0.5 else { return } |
| 1553 | let path = NSBezierPath() |
| 1554 | path.move(to: NSPoint(x: x0, y: bottom)) |
| 1555 | path.line(to: NSPoint(x: x1, y: top)) |
| 1556 | path.line(to: NSPoint(x: leading ? x0 : x1, y: top)) |
| 1557 | path.close() |
| 1558 | NSColor.black.withAlphaComponent(0.45).setFill() |
| 1559 | path.fill() |
| 1560 | let line = NSBezierPath() |
| 1561 | line.move(to: NSPoint(x: x0, y: bottom)) |
| 1562 | line.line(to: NSPoint(x: x1, y: top)) |
| 1563 | NSColor.white.withAlphaComponent(0.8).setStroke() |
| 1564 | line.lineWidth = 1.2 |
| 1565 | line.stroke() |
| 1566 | } |
| 1567 | let xIn = xFor(clip.start + clip.fadeIn) |
| 1568 | let xOut = xFor(clip.end - clip.fadeOut) |
| 1569 | if clip.fadeIn > 0.001 { fadeShape(from: xFor(clip.start), to: xIn, leading: true) } |
| 1570 | if clip.fadeOut > 0.001 { fadeShape(from: xFor(clip.end), to: xOut, leading: false) } |
| 1571 | drawFadeHandles(clip, rect: rect, selected: selected) |
| 1572 | } |
| 1573 | |
| 1574 | /// Fade handles (always visible so fades stay discoverable). Split out of |
| 1575 | /// `drawFades` because narrow (LOD) clips draw the handles — they're the |
| 1576 | /// speckled texture of a dense audio lane — without the fade shapes. |
| 1577 | private func drawFadeHandles(_ clip: Clip, rect: NSRect, selected: Bool) { |
| 1578 | let top = rect.minY + 13 |
| 1579 | let xIn = xFor(clip.start + clip.fadeIn) |
| 1580 | let xOut = xFor(clip.end - clip.fadeOut) |
| 1581 | for x in [xIn, xOut] { |
| 1582 | let r = NSRect(x: x - 3.5, y: top - 3.5 + 4, width: 7, height: 7) |
| 1583 | (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill() |
| 1584 | NSBezierPath(ovalIn: r).fill() |
| 1585 | } |
| 1586 | } |
| 1587 | |
| 1588 | private func drawBoardThumb(_ clip: Clip, rect: NSRect) { |
| 1589 | guard let board = clip.board, rect.height > 20 else { return } |
| 1590 | let img = boards.composite(for: board) |
| 1591 | let h = rect.height - 15 |
| 1592 | let w = h * CGFloat(board.width / board.height) |
| 1593 | var x = rect.minX + 1 |
| 1594 | while x < rect.maxX - 1 { |
| 1595 | img.draw(in: NSRect(x: x, y: rect.minY + 14, width: min(w, rect.maxX - 1 - x), |
| 1596 | height: h), |
| 1597 | from: NSRect(x: 0, y: 0, |
| 1598 | width: img.size.width * min(1, (rect.maxX - 1 - x) / w), |
| 1599 | height: img.size.height), |
| 1600 | operation: .sourceOver, fraction: 1) |
| 1601 | x += w + 2 |
| 1602 | break // one panel image; boards are one still, no need to tile |
| 1603 | } |
| 1604 | Self.bakedSymbol("pencil.and.outline", |
| 1605 | tint: NSColor(calibratedWhite: 0.2, alpha: 1), |
| 1606 | key: "board.pencil")? |
| 1607 | .draw(in: NSRect(x: rect.minX + 4, y: rect.minY + 16, width: 11, height: 11), |
| 1608 | from: .zero, operation: .sourceOver, fraction: 0.9) |
| 1609 | } |
| 1610 | |
| 1611 | // MARK: - Fusion comps band |
| 1612 | |
| 1613 | private func drawFusionBand() { |
| 1614 | guard fusionBandH > 0 else { return } |
| 1615 | let band = NSRect(x: 0, y: fusionTop, width: bounds.width, height: fusionBandH) |
| 1616 | NSColor(calibratedRed: 0.16, green: 0.14, blue: 0.05, alpha: 1).setFill() |
| 1617 | band.fill() |
| 1618 | FusionComps.yellow.withAlphaComponent(0.5).setFill() |
| 1619 | NSRect(x: 0, y: band.maxY - 1, width: bounds.width, height: 1).fill() |
| 1620 | |
| 1621 | let fps = project.fps |
| 1622 | let preferred = Set(project.preferredTakes) |
| 1623 | for (comp, lane, lanes) in comps.stacked() { |
| 1624 | let x0 = xFor(comp.startSeconds(fps: fps)) |
| 1625 | let x1 = xFor(comp.endSeconds(fps: fps)) |
| 1626 | guard x1 > headerW, x0 < bounds.width else { continue } |
| 1627 | let subH = (band.height - 6) / CGFloat(lanes) |
| 1628 | let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, |
| 1629 | width: max(2, x1 - x0), height: subH - 1) |
| 1630 | let isPreferred = preferred.contains(comp.name) |
| 1631 | let selected = comps.selectedCompPath == comp.path |
| 1632 | FusionComps.yellow.withAlphaComponent(isPreferred ? 0.95 : 0.55).setFill() |
| 1633 | let p = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), xRadius: 3, yRadius: 3) |
| 1634 | p.fill() |
| 1635 | if selected { |
| 1636 | Theme.selection.setStroke() |
| 1637 | let b = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 3, yRadius: 3) |
| 1638 | b.lineWidth = 2 |
| 1639 | b.stroke() |
| 1640 | } |
| 1641 | var label = comp.title.isEmpty ? comp.name : comp.title |
| 1642 | if isPreferred { label = "★ " + label } |
| 1643 | let attrs: [NSAttributedString.Key: Any] = [ |
| 1644 | .font: NSFont.systemFont(ofSize: min(10, subH - 4), weight: .semibold), |
| 1645 | .foregroundColor: NSColor.black.withAlphaComponent(0.8), |
| 1646 | ] |
| 1647 | NSGraphicsContext.current?.saveGraphicsState() |
| 1648 | p.addClip() |
| 1649 | label.draw(at: NSPoint(x: max(x0, headerW) + 4, |
| 1650 | y: r.midY - label.size(withAttributes: attrs).height / 2), |
| 1651 | withAttributes: attrs) |
| 1652 | NSGraphicsContext.current?.restoreGraphicsState() |
| 1653 | } |
| 1654 | } |
| 1655 | |
| 1656 | private func compAt(point: NSPoint) -> FusionComp? { |
| 1657 | guard fusionBandH > 0, point.y > fusionTop, point.y < fusionTop + fusionBandH |
| 1658 | else { return nil } |
| 1659 | let fps = project.fps |
| 1660 | let band = NSRect(x: 0, y: fusionTop, width: bounds.width, height: fusionBandH) |
| 1661 | for (comp, lane, lanes) in comps.stacked() { |
| 1662 | let x0 = xFor(comp.startSeconds(fps: fps)) |
| 1663 | let x1 = xFor(comp.endSeconds(fps: fps)) |
| 1664 | let subH = (band.height - 6) / CGFloat(lanes) |
| 1665 | let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH, |
| 1666 | width: max(2, x1 - x0), height: subH - 1) |
| 1667 | if r.contains(point) { return comp } |
| 1668 | } |
| 1669 | return nil |
| 1670 | } |
| 1671 | |
| 1672 | // MARK: - Optimization strip |
| 1673 | |
| 1674 | /// Per-column optimization state for the thin band under the ruler: |
| 1675 | /// GREEN — every proxy chunk the visible tracks need at that time is |
| 1676 | /// built at the preview-quality target; DIM GREEN — built but below |
| 1677 | /// target (usable now, upgrade queued); YELLOW — building right now (or |
| 1678 | /// a rescue slice is standing in); RED — no proxy on disk (never built, |
| 1679 | /// evicted, or failed). Aggregated worst-wins across visible video |
| 1680 | /// tracks, so one glance says whether playing here will be instant. |
| 1681 | /// |
| 1682 | /// The runs are cached — recomputed only when the view moves (origin/ |
| 1683 | /// zoom/width) or something actually changed (.projectChanged / |
| 1684 | /// .mediaStatusChanged clear the key) — so playback frames just re-fill |
| 1685 | /// a few dozen rects. |
| 1686 | private var stripRuns: [(x: CGFloat, w: CGFloat, color: NSColor)] = [] |
| 1687 | private var stripKey: (origin: Double, pps: Double, width: CGFloat)? |
| 1688 | |
| 1689 | private func rebuildStripIfNeeded() { |
| 1690 | if let k = stripKey, k.origin == originSecond, k.pps == pxPerSecond, |
| 1691 | k.width == bounds.width { return } |
| 1692 | stripKey = (originSecond, pxPerSecond, bounds.width) |
| 1693 | stripRuns.removeAll(keepingCapacity: true) |
| 1694 | rebuildSceneIfNeeded() |
| 1695 | |
| 1696 | let colW: CGFloat = 2 |
| 1697 | let ncols = Int((bounds.width - headerW) / colW) + 1 |
| 1698 | guard ncols > 0 else { return } |
| 1699 | var vals = [UInt8](repeating: .max, count: ncols) |
| 1700 | let leftSec = secondsFor(headerW), rightSec = secondsFor(bounds.width) |
| 1701 | var snaps: [String: ChunkManager.StripSnapshot?] = [:] |
| 1702 | |
| 1703 | func mark(_ lo: Double, _ hi: Double, _ v: UInt8) { |
| 1704 | let c0 = max(0, Int((xFor(lo) - headerW) / colW)) |
| 1705 | let c1 = min(ncols - 1, Int((xFor(hi) - headerW) / colW)) |
| 1706 | guard c1 >= c0 else { return } |
| 1707 | for c in c0...c1 where vals[c] > v { vals[c] = v } |
| 1708 | } |
| 1709 | |
| 1710 | for ref in laneRows { |
| 1711 | guard ref.videoIndex != nil, !session.hiddenTracks.contains(ref) else { continue } |
| 1712 | let clips = clipsByLane[ref] ?? [] |
| 1713 | for i in visibleIndexRange(clips, lane: ref, left: leftSec, right: rightSec) { |
| 1714 | let clip = clips[i] |
| 1715 | guard clip.kind == .video, |
| 1716 | let media = project.media(clip.mediaId), !media.isAudio, |
| 1717 | media.duration > 0 else { continue } |
| 1718 | let a = max(clip.start, leftSec), b = min(clip.end, rightSec) |
| 1719 | guard b > a else { continue } |
| 1720 | let snap: ChunkManager.StripSnapshot? |
| 1721 | if let cached = snaps[media.cacheKey] { snap = cached } |
| 1722 | else { snap = ctx.chunks.stripSnapshot(media: media); snaps[media.cacheKey] = snap } |
| 1723 | guard let snap else { mark(a, b, 0); continue } // unscanned: unknown |
| 1724 | if snap.fullProxy { mark(a, b, 3); continue } // legacy whole-file proxy |
| 1725 | let srcA = clip.srcIn + (a - clip.start) * clip.speed |
| 1726 | let srcB = clip.srcIn + (b - clip.start) * clip.speed |
| 1727 | let ci = min(snap.n - 1, ChunkManager.chunkIndex(forSource: min(srcA, srcB))) |
| 1728 | let cj = min(snap.n - 1, ChunkManager.chunkIndex(forSource: max(srcA, srcB) - 1e-6)) |
| 1729 | for idx in ci...max(ci, cj) { |
| 1730 | let v: UInt8 |
| 1731 | if let w = snap.built[idx] { |
| 1732 | v = w >= snap.target ? 3 : 2 |
| 1733 | } else if snap.inFlight.contains(idx) || snap.partial.contains(idx) { |
| 1734 | v = 1 |
| 1735 | } else { |
| 1736 | v = 0 // missing, evicted, or failed |
| 1737 | } |
| 1738 | let t0 = clip.start |
| 1739 | + (Double(idx) * ChunkManager.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed) |
| 1740 | let t1 = clip.start |
| 1741 | + (Double(idx + 1) * ChunkManager.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed) |
| 1742 | mark(max(min(t0, t1), a), min(max(t0, t1), b), v) |
| 1743 | } |
| 1744 | } |
| 1745 | } |
| 1746 | |
| 1747 | // Compress equal-valued columns into fill runs. |
| 1748 | let palette = [Theme.stripMissing, Theme.stripBuilding, |
| 1749 | Theme.stripUsable, Theme.stripFull] |
| 1750 | var c = 0 |
| 1751 | while c < ncols { |
| 1752 | let v = vals[c] |
| 1753 | var e = c + 1 |
| 1754 | while e < ncols, vals[e] == v { e += 1 } |
| 1755 | if v != .max { |
| 1756 | stripRuns.append((x: headerW + CGFloat(c) * colW, |
| 1757 | w: CGFloat(e - c) * colW, |
| 1758 | color: palette[Int(v)])) |
| 1759 | } |
| 1760 | c = e |
| 1761 | } |
| 1762 | } |
| 1763 | |
| 1764 | private func drawOptimizeStrip() { |
| 1765 | Theme.timelineBg.setFill() |
| 1766 | NSRect(x: 0, y: rulerH, width: bounds.width, height: stripH).fill() |
| 1767 | rebuildStripIfNeeded() |
| 1768 | let y = rulerH + 1 |
| 1769 | for run in stripRuns { |
| 1770 | run.color.setFill() |
| 1771 | NSRect(x: run.x, y: y, width: run.w, height: stripH - 2).fill() |
| 1772 | } |
| 1773 | } |
| 1774 | |
| 1775 | // MARK: - Ruler / playhead / indicators |
| 1776 | |
| 1777 | private func drawRuler() { |
| 1778 | Theme.rulerBg.setFill() |
| 1779 | NSRect(x: 0, y: 0, width: bounds.width, height: rulerH).fill() |
| 1780 | Theme.rulerLine.setFill() |
| 1781 | NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill() |
| 1782 | |
| 1783 | let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800] |
| 1784 | let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600 |
| 1785 | let labelAttrs: [NSAttributedString.Key: Any] = [ |
| 1786 | .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular), |
| 1787 | .foregroundColor: Theme.subtleLabel, |
| 1788 | ] |
| 1789 | var s = (originSecond / major).rounded(.down) * major |
| 1790 | while xFor(s) < bounds.width { |
| 1791 | let x = xFor(s) |
| 1792 | if x >= headerW, s >= 0 { |
| 1793 | Theme.tickMajor.setFill() |
| 1794 | NSRect(x: x, y: rulerH - 8, width: 1, height: 8).fill() |
| 1795 | label(for: s).draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs) |
| 1796 | // minor ticks |
| 1797 | for m in 1..<5 { |
| 1798 | let mx = xFor(s + major * Double(m) / 5) |
| 1799 | Theme.tickMinor.setFill() |
| 1800 | NSRect(x: mx, y: rulerH - 4, width: 1, height: 4).fill() |
| 1801 | } |
| 1802 | } |
| 1803 | s += major |
| 1804 | } |
| 1805 | } |
| 1806 | |
| 1807 | private func label(for seconds: Double) -> String { |
| 1808 | let total = Int(seconds.rounded()) |
| 1809 | if seconds < 1 && seconds > 0 { return String(format: "%.2f", seconds) } |
| 1810 | let h = total / 3600, m = (total / 60) % 60, sec = total % 60 |
| 1811 | return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%d:%02d", m, sec) |
| 1812 | } |
| 1813 | |
| 1814 | /// The loop range. With both ends set, a tinted band over the lanes between |
| 1815 | /// in and out plus `[` / `]` bracket handles in the ruler. A lone point |
| 1816 | /// (only in, or only out) shows just an arrow flag in the ruler — like a |
| 1817 | /// marker — rather than tinting the whole timeline. Green while cycling, |
| 1818 | /// amber when set but looping is off. Drawn under markers and playhead. |
| 1819 | private func drawInOut() { |
| 1820 | let pc = playback |
| 1821 | guard pc.hasInOut else { return } |
| 1822 | let color = pc.loops ? NSColor.systemGreen : NSColor.systemOrange |
| 1823 | |
| 1824 | // Lone point: draw a flag, no band. |
| 1825 | guard let i0 = pc.inPoint, let o0 = pc.outPoint else { |
| 1826 | if let i = pc.inPoint { drawInOutFlag(at: i, color: color, isIn: true) } |
| 1827 | if let o = pc.outPoint { drawInOutFlag(at: o, color: color, isIn: false) } |
| 1828 | return |
| 1829 | } |
| 1830 | |
| 1831 | // Full range: tinted band over the lanes. |
| 1832 | let xLo = max(headerW, xFor(i0)) |
| 1833 | let xHi = min(bounds.width, xFor(o0)) |
| 1834 | if xHi > xLo { |
| 1835 | color.withAlphaComponent(0.10).setFill() |
| 1836 | NSRect(x: xLo, y: rulerH, width: xHi - xLo, height: bounds.height - rulerH).fill() |
| 1837 | color.withAlphaComponent(0.85).setFill() |
| 1838 | NSRect(x: xLo, y: rulerH - 3, width: xHi - xLo, height: 3).fill() |
| 1839 | } |
| 1840 | // In bracket: stem with feet pointing right (into the range). |
| 1841 | let xi = xFor(i0) |
| 1842 | if xi >= headerW - 2, xi <= bounds.width { |
| 1843 | color.setFill() |
| 1844 | NSRect(x: xi, y: 0, width: 2, height: rulerH).fill() |
| 1845 | NSRect(x: xi, y: rulerH - 3, width: 7, height: 3).fill() |
| 1846 | NSRect(x: xi, y: 0, width: 7, height: 3).fill() |
| 1847 | } |
| 1848 | // Out bracket: feet point left. |
| 1849 | let xo = xFor(o0) |
| 1850 | if xo >= headerW, xo <= bounds.width + 2 { |
| 1851 | color.setFill() |
| 1852 | NSRect(x: xo - 2, y: 0, width: 2, height: rulerH).fill() |
| 1853 | NSRect(x: xo - 7, y: rulerH - 3, width: 7, height: 3).fill() |
| 1854 | NSRect(x: xo - 7, y: 0, width: 7, height: 3).fill() |
| 1855 | } |
| 1856 | } |
| 1857 | |
| 1858 | /// A lone in/out point reads as a triangular arrow flag in the ruler plus a |
| 1859 | /// thin stem down the lanes, like a marker. The arrow points into the range: |
| 1860 | /// right for an in point, left for an out point. |
| 1861 | private func drawInOutFlag(at t: Double, color: NSColor, isIn: Bool) { |
| 1862 | let x = xFor(t) |
| 1863 | guard x >= headerW - 2, x <= bounds.width + 2 else { return } |
| 1864 | let stemX = isIn ? x : x - 1 |
| 1865 | // Faint pole down the lanes. |
| 1866 | color.withAlphaComponent(0.5).setFill() |
| 1867 | NSRect(x: stemX, y: rulerH, width: 1, height: bounds.height - rulerH).fill() |
| 1868 | // Triangular arrow in the ruler, anchored on the exact time. |
| 1869 | let h: CGFloat = 13 |
| 1870 | let top = rulerH - h - 1 |
| 1871 | let beak: CGFloat = 8 |
| 1872 | color.setFill() |
| 1873 | NSRect(x: stemX, y: top, width: 1, height: rulerH - top).fill() |
| 1874 | let arrow = NSBezierPath() |
| 1875 | arrow.move(to: NSPoint(x: x, y: top)) |
| 1876 | arrow.line(to: NSPoint(x: x + (isIn ? beak : -beak), y: top + h / 2)) |
| 1877 | arrow.line(to: NSPoint(x: x, y: top + h)) |
| 1878 | arrow.close() |
| 1879 | arrow.fill() |
| 1880 | } |
| 1881 | |
| 1882 | private func drawPlayhead() { |
| 1883 | let x = xFor(playback.playhead) |
| 1884 | guard x >= headerW, x <= bounds.width else { return } |
| 1885 | NSColor.systemRed.withAlphaComponent(0.9).setFill() |
| 1886 | NSRect(x: x, y: 0, width: 1.5, height: bounds.height).fill() |
| 1887 | let tri = NSBezierPath() |
| 1888 | tri.move(to: NSPoint(x: x - 5, y: 0)) |
| 1889 | tri.line(to: NSPoint(x: x + 6.5, y: 0)) |
| 1890 | tri.line(to: NSPoint(x: x + 0.75, y: 8)) |
| 1891 | tri.close() |
| 1892 | tri.fill() |
| 1893 | } |
| 1894 | |
| 1895 | private static let markerLabelAttrs: [NSAttributedString.Key: Any] = [ |
| 1896 | .font: NSFont.systemFont(ofSize: 9, weight: .semibold), |
| 1897 | .foregroundColor: NSColor.white, |
| 1898 | ] |
| 1899 | |
| 1900 | /// Blue markers: a thin line down the lanes plus a clickable flag in the |
| 1901 | /// ruler. A named marker carries its name inside the flag itself; an |
| 1902 | /// unnamed one gets a small plain pennant. Drawn under the red playhead. |
| 1903 | private func drawMarkers() { |
| 1904 | let blue = NSColor.systemBlue |
| 1905 | for m in project.sortedMarkers { |
| 1906 | let x = xFor(m.time) |
| 1907 | guard x >= headerW, x <= bounds.width else { continue } |
| 1908 | // Flag hanging off the pole: a straight left edge on the pole and a |
| 1909 | // triangular pennant point on the right. A named marker carries the |
| 1910 | // name inside; an unnamed one is the same-height flag, just narrow. |
| 1911 | let h: CGFloat = 13 |
| 1912 | let top = rulerH - h - 1 |
| 1913 | // Pole: a faint line down the lanes, plus a solid stem only as tall |
| 1914 | // as the flag (not running up to the top of the ruler). |
| 1915 | blue.withAlphaComponent(0.5).setFill() |
| 1916 | NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill() |
| 1917 | blue.withAlphaComponent(0.95).setFill() |
| 1918 | NSRect(x: x, y: top, width: 1, height: rulerH - top).fill() |
| 1919 | let beak: CGFloat = 6 |
| 1920 | let body: CGFloat = m.label.isEmpty |
| 1921 | ? 8 |
| 1922 | : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 10 |
| 1923 | let flag = NSBezierPath() |
| 1924 | flag.move(to: NSPoint(x: x + 1, y: top)) // top-left |
| 1925 | flag.line(to: NSPoint(x: x + 1 + body, y: top)) // top-right |
| 1926 | flag.line(to: NSPoint(x: x + 1 + body + beak, y: top + h / 2)) // point |
| 1927 | flag.line(to: NSPoint(x: x + 1 + body, y: top + h)) // bottom-right |
| 1928 | flag.line(to: NSPoint(x: x + 1, y: top + h)) // bottom-left |
| 1929 | flag.close() |
| 1930 | flag.fill() |
| 1931 | if !m.label.isEmpty { |
| 1932 | (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2), |
| 1933 | withAttributes: Self.markerLabelAttrs) |
| 1934 | } |
| 1935 | } |
| 1936 | } |
| 1937 | |
| 1938 | /// Hit region for a marker's ruler flag (click to seek, double-click to |
| 1939 | /// rename, right-click for its menu). |
| 1940 | private func markerHandleRect(_ m: Marker) -> NSRect { |
| 1941 | // Cover the whole flag: pole + body + beak (see drawMarkers). |
| 1942 | let w: CGFloat = m.label.isEmpty |
| 1943 | ? 20 |
| 1944 | : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21 |
| 1945 | return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH) |
| 1946 | } |
| 1947 | /// Topmost marker flag under a ruler point, if any. |
| 1948 | private func markerAt(point p: NSPoint) -> Marker? { |
| 1949 | guard p.y <= rulerH else { return nil } |
| 1950 | return project.sortedMarkers.reversed().first { markerHandleRect($0).contains(p) } |
| 1951 | } |
| 1952 | |
| 1953 | private func drawSnapIndicator() { |
| 1954 | guard let target = activeSnapTarget else { return } |
| 1955 | let x = xFor(target) |
| 1956 | NSColor.systemYellow.withAlphaComponent(0.8).setFill() |
| 1957 | NSRect(x: x, y: 0, width: 1, height: bounds.height).fill() |
| 1958 | } |
| 1959 | |
| 1960 | private func drawBoxSelect() { |
| 1961 | guard drag.mode == .box, drag.moved else { return } |
| 1962 | let r = boxRect() |
| 1963 | Theme.label.withAlphaComponent(0.08).setFill() |
| 1964 | r.fill() |
| 1965 | Theme.label.withAlphaComponent(0.6).setStroke() |
| 1966 | let p = NSBezierPath(rect: r) |
| 1967 | p.lineWidth = 1 |
| 1968 | p.stroke() |
| 1969 | } |
| 1970 | |
| 1971 | private func boxRect() -> NSRect { |
| 1972 | NSRect(x: min(drag.startPoint.x, lastMousePoint.x), |
| 1973 | y: min(drag.startPoint.y, lastMousePoint.y), |
| 1974 | width: abs(lastMousePoint.x - drag.startPoint.x), |
| 1975 | height: abs(lastMousePoint.y - drag.startPoint.y)) |
| 1976 | } |
| 1977 | |
| 1978 | // MARK: - Mouse editing |
| 1979 | |
| 1980 | private enum DragMode { |
| 1981 | case none, scrub, move, trimIn, trimOut, rippleOut, rippleIn, slip, |
| 1982 | stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack, |
| 1983 | hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom |
| 1984 | } |
| 1985 | |
| 1986 | private struct BarDrag { |
| 1987 | var domainLo = 0.0, domainHi = 0.0 |
| 1988 | var origOrigin = 0.0 |
| 1989 | var origPps = 20.0 |
| 1990 | var origScrollY: CGFloat = 0 |
| 1991 | var origScale: CGFloat = 1 |
| 1992 | var origContentH: CGFloat = 1 |
| 1993 | var thumb = NSRect.zero |
| 1994 | } |
| 1995 | private var barDrag = BarDrag() |
| 1996 | private struct DragState { |
| 1997 | var mode: DragMode = .none |
| 1998 | var clipId: UUID? |
| 1999 | var startPoint = NSPoint.zero |
| 2000 | var origClip: Clip? |
| 2001 | var origSelection: [UUID: Clip] = [:] |
| 2002 | var baseSelection: Set<UUID> = [] // box select: selection before drag |
| 2003 | var resizeRow: Int? |
| 2004 | var resizeOrigH: CGFloat = 0 |
| 2005 | var moved = false |
| 2006 | var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag |
| 2007 | } |
| 2008 | private var drag = DragState() |
| 2009 | |
| 2010 | // Middle-button drag grabs the timeline and hand-pans it in both axes |
| 2011 | // (time horizontally, tracks vertically), independent of any clip edit. |
| 2012 | private struct PanDrag { |
| 2013 | var active = false |
| 2014 | var start = NSPoint.zero |
| 2015 | var origOrigin = 0.0 |
| 2016 | var origScrollY: CGFloat = 0 |
| 2017 | } |
| 2018 | private var panDrag = PanDrag() |
| 2019 | |
| 2020 | private var activeSnapTarget: Double? |
| 2021 | private var dragHintRow: Int? |
| 2022 | private var lastMousePoint = NSPoint.zero |
| 2023 | |
| 2024 | override func mouseDown(with event: NSEvent) { |
| 2025 | window?.makeFirstResponder(self) |
| 2026 | let p = convert(event.locationInWindow, from: nil) |
| 2027 | lastMousePoint = p |
| 2028 | drag = DragState() |
| 2029 | drag.startPoint = p |
| 2030 | |
| 2031 | if let barMode = scrollbarHit(p) { |
| 2032 | drag.mode = barMode |
| 2033 | let (lo, hi) = hDomain() |
| 2034 | barDrag = BarDrag(domainLo: lo, domainHi: hi, |
| 2035 | origOrigin: originSecond, origPps: pxPerSecond, |
| 2036 | origScrollY: scrollY, origScale: session.laneScale, |
| 2037 | origContentH: lanesContentHeight, |
| 2038 | thumb: [.vBarPan, .vBarTop, .vBarBottom].contains(barMode) |
| 2039 | ? vThumbRect() : hThumbRect()) |
| 2040 | return |
| 2041 | } |
| 2042 | |
| 2043 | // A marker flag in the ruler: click parks the playhead on it. Rename |
| 2044 | // via its right-click menu (name field is inline there). Checked |
| 2045 | // before the scrub fallthrough. |
| 2046 | if p.y < rulerH, let m = markerAt(point: p) { |
| 2047 | playback.setRate(0) |
| 2048 | playback.seek(to: m.time) |
| 2049 | return |
| 2050 | } |
| 2051 | |
| 2052 | if p.y < rulerH + stripH { // ruler + optimization strip both scrub |
| 2053 | drag.mode = .scrub |
| 2054 | playback.setRate(0) |
| 2055 | playback.seek(to: max(0, quantize(secondsFor(p.x)))) |
| 2056 | return |
| 2057 | } |
| 2058 | |
| 2059 | // Fusion band header: hide preview (top) / focus (bottom) |
| 2060 | if fusionBandH > 0, p.x < headerW, p.y > fusionTop, p.y < lanesTop { |
| 2061 | if p.y < fusionTop + fusionBandH * 0.55 { session.fusionHidden.toggle() } |
| 2062 | else { session.fusionFocus.toggle() } |
| 2063 | needsDisplay = true |
| 2064 | return |
| 2065 | } |
| 2066 | // Fusion comps band |
| 2067 | if let comp = compAt(point: p) { |
| 2068 | comps.selectedCompPath = comp.path |
| 2069 | if event.clickCount == 2 { comps.openInFusion(comp) } |
| 2070 | needsDisplay = true |
| 2071 | return |
| 2072 | } |
| 2073 | if p.y < lanesTop { return } |
| 2074 | |
| 2075 | // Track header buttons |
| 2076 | if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { |
| 2077 | let lane = laneRect(row: row) |
| 2078 | guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden |
| 2079 | let rects = headerButtonRects(lane: lane) |
| 2080 | if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) { |
| 2081 | openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c) |
| 2082 | } else if p.y < lane.midY { |
| 2083 | session.toggleHidden(ref) |
| 2084 | } else { |
| 2085 | session.toggleFocus(ref) |
| 2086 | } |
| 2087 | needsDisplay = true |
| 2088 | return |
| 2089 | } |
| 2090 | |
| 2091 | // Track height resize on lane boundaries |
| 2092 | if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) { |
| 2093 | drag.mode = .resizeTrack |
| 2094 | drag.resizeRow = row |
| 2095 | drag.resizeOrigH = laneHeight(ref) |
| 2096 | return |
| 2097 | } |
| 2098 | |
| 2099 | // Clicking a red overlap selects both offenders (S then resolves it). |
| 2100 | if let o = overlapAt(point: p), clipAt(point: p) != nil { |
| 2101 | store.selection = [o.a.id, o.b.id] |
| 2102 | drag.mode = .move |
| 2103 | drag.clipId = o.b.id |
| 2104 | drag.origClip = o.b |
| 2105 | drag.origSelection = Dictionary(uniqueKeysWithValues: |
| 2106 | project.clips.filter { store.selection.contains($0.id) }.map { ($0.id, $0) }) |
| 2107 | store.beginGesture() |
| 2108 | return |
| 2109 | } |
| 2110 | |
| 2111 | guard let (clip, row) = clipAt(point: p) else { |
| 2112 | // Empty area: box select (click without drag = deselect). |
| 2113 | drag.mode = .box |
| 2114 | drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : [] |
| 2115 | store.selection = drag.baseSelection |
| 2116 | return |
| 2117 | } |
| 2118 | |
| 2119 | if event.clickCount == 2, clip.kind == .storyboard { |
| 2120 | store.selection = [clip.id] |
| 2121 | StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) |
| 2122 | return |
| 2123 | } |
| 2124 | |
| 2125 | // Blade tool: click a clip to split it right there. |
| 2126 | if session.mainTool == .blade { |
| 2127 | store.selection = [clip.id] |
| 2128 | bladeAtPlayhead(at: quantize(secondsFor(p.x)), |
| 2129 | ids: [clip.id], |
| 2130 | rightBoard: { boards.duplicate($0) }) |
| 2131 | return |
| 2132 | } |
| 2133 | |
| 2134 | // Selection |
| 2135 | if event.modifierFlags.contains(.shift) { |
| 2136 | if store.selection.contains(clip.id) { store.selection.remove(clip.id) } |
| 2137 | else { store.selection.insert(clip.id) } |
| 2138 | } else if !store.selection.contains(clip.id) { |
| 2139 | store.selection = [clip.id] |
| 2140 | } else if store.selection.count > 1 { |
| 2141 | // Clicking an already-selected clip in a multi-selection keeps the |
| 2142 | // group intact so a drag can move it, but a click without a drag |
| 2143 | // collapses to just this clip (deselecting the rest) on mouseUp. |
| 2144 | drag.collapseTo = clip.id |
| 2145 | } |
| 2146 | |
| 2147 | let rect = clipRect(clip, row: row) |
| 2148 | let edge: CGFloat = 7 |
| 2149 | |
| 2150 | // Audio fade handles beat edge trims. |
| 2151 | if clip.kind == .audio { |
| 2152 | let handleY = rect.minY + 13 + 4 |
| 2153 | let xIn = xFor(clip.start + clip.fadeIn) |
| 2154 | let xOut = xFor(clip.end - clip.fadeOut) |
| 2155 | if abs(p.x - xIn) < 7, abs(p.y - handleY) < 9 { |
| 2156 | drag.mode = .fadeIn |
| 2157 | } else if abs(p.x - xOut) < 7, abs(p.y - handleY) < 9 { |
| 2158 | drag.mode = .fadeOut |
| 2159 | } |
| 2160 | } |
| 2161 | |
| 2162 | if drag.mode == .none { |
| 2163 | let stretch = event.modifierFlags.contains(.command) && clip.kind == .video |
| 2164 | let opt = event.modifierFlags.contains(.option) |
| 2165 | let onIn = p.x - rect.minX < edge |
| 2166 | let onOut = rect.maxX - p.x < edge |
| 2167 | if opt, onOut { |
| 2168 | drag.mode = .rippleOut // ⌥-drag out edge: ripple the tail away |
| 2169 | } else if opt, onIn, clip.kind != .storyboard { |
| 2170 | drag.mode = .rippleIn // ⌥-drag in edge: ripple-trim the head |
| 2171 | } else if opt || session.mainTool == .slide { |
| 2172 | drag.mode = .slip // ⌥-drag body: slip source under the clip |
| 2173 | } else if onIn { |
| 2174 | drag.mode = stretch ? .stretchIn : .trimIn |
| 2175 | } else if onOut { |
| 2176 | drag.mode = stretch ? .stretchOut : .trimOut |
| 2177 | } else { |
| 2178 | drag.mode = .move |
| 2179 | } |
| 2180 | } |
| 2181 | |
| 2182 | // Storyboard panels are start-only: clicking one parks the playhead on |
| 2183 | // it, and the body can't be dragged — only the edges (and nudges) move |
| 2184 | // the start. Everything else still works (blade, trims, ripple). |
| 2185 | if clip.kind == .storyboard { |
| 2186 | if drag.mode == .move || drag.mode == .slip { |
| 2187 | playback.setRate(0) |
| 2188 | playback.seek(to: quantize(clip.start)) |
| 2189 | drag = DragState() |
| 2190 | needsDisplay = true |
| 2191 | return |
| 2192 | } |
| 2193 | } |
| 2194 | |
| 2195 | drag.clipId = clip.id |
| 2196 | drag.origClip = clip |
| 2197 | // Moves and edge trims carry the whole selection + link-mates: dragging |
| 2198 | // one edge resizes every selected/linked clip by the same amount. |
| 2199 | // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.) |
| 2200 | var editSet = project.expandLinks(store.selection) |
| 2201 | editSet.insert(clip.id) |
| 2202 | drag.origSelection = Dictionary(uniqueKeysWithValues: |
| 2203 | project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) }) |
| 2204 | store.beginGesture() |
| 2205 | } |
| 2206 | |
| 2207 | override func mouseDragged(with event: NSEvent) { |
| 2208 | let p = convert(event.locationInWindow, from: nil) |
| 2209 | lastMousePoint = p |
| 2210 | let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond |
| 2211 | activeSnapTarget = nil |
| 2212 | dragHintRow = nil |
| 2213 | |
| 2214 | // Dragging against the view edges pans the timeline (there's no |
| 2215 | // enclosing scroll view, so the playhead could never leave the screen). |
| 2216 | if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip, |
| 2217 | .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) { |
| 2218 | if p.x > bounds.width - 30 { |
| 2219 | originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond |
| 2220 | } else if p.x < headerW + 20 { |
| 2221 | originSecond -= Double(headerW + 20 - p.x) * 0.12 / pxPerSecond |
| 2222 | } |
| 2223 | originSecond = clampOrigin(originSecond) |
| 2224 | } |
| 2225 | |
| 2226 | switch drag.mode { |
| 2227 | case .none: return |
| 2228 | case .scrub: |
| 2229 | playback.seek(to: max(0, quantize(secondsFor(p.x)))) |
| 2230 | case .move: dragMove(p: p, dSec: dSec) |
| 2231 | case .trimIn: dragTrimIn(dSec: dSec) |
| 2232 | case .trimOut: dragTrimOut(dSec: dSec) |
| 2233 | case .rippleOut: dragRippleOut(dSec: dSec) |
| 2234 | case .rippleIn: dragRippleIn(dSec: dSec) |
| 2235 | case .slip: dragSlip(dSec: dSec) |
| 2236 | case .stretchIn: dragStretch(dSec: dSec, fromStart: true) |
| 2237 | case .stretchOut: dragStretch(dSec: dSec, fromStart: false) |
| 2238 | case .fadeIn, .fadeOut: dragFade(p: p) |
| 2239 | case .hBarPan: |
| 2240 | let span = barDrag.domainHi - barDrag.domainLo |
| 2241 | let d = Double(p.x - drag.startPoint.x) / Double(max(1, hBarRect.width)) * span |
| 2242 | originSecond = clampOrigin(barDrag.origOrigin + d) |
| 2243 | case .hBarLeft, .hBarRight: |
| 2244 | let bar = hBarRect |
| 2245 | let span = barDrag.domainHi - barDrag.domainLo |
| 2246 | let t = barDrag.domainLo + Double((p.x - bar.minX) / max(1, bar.width)) * span |
| 2247 | let viewW = Double(bounds.width - headerW) |
| 2248 | if drag.mode == .hBarLeft { |
| 2249 | let t1 = barDrag.origOrigin + viewW / barDrag.origPps |
| 2250 | let newT0 = min(max(t, barDrag.domainLo), t1 - viewW / 4000) |
| 2251 | pxPerSecond = min(max(viewW / (t1 - newT0), 0.05), 4000) |
| 2252 | originSecond = t1 - viewW / pxPerSecond |
| 2253 | } else { |
| 2254 | let t0 = barDrag.origOrigin |
| 2255 | let newT1 = max(min(t, barDrag.domainHi), t0 + viewW / 4000) |
| 2256 | pxPerSecond = min(max(viewW / (newT1 - t0), 0.05), 4000) |
| 2257 | originSecond = t0 |
| 2258 | } |
| 2259 | case .vBarPan: |
| 2260 | let d = (p.y - drag.startPoint.y) / max(1, vBarRect.height) * barDrag.origContentH |
| 2261 | scrollY = min(max(0, barDrag.origScrollY + d), maxScrollY) |
| 2262 | case .vBarTop, .vBarBottom: |
| 2263 | let bar = vBarRect |
| 2264 | let viewH = max(1, bounds.height - lanesTop) |
| 2265 | var top = barDrag.thumb.minY, bottom = barDrag.thumb.maxY |
| 2266 | if drag.mode == .vBarTop { top = min(max(bar.minY, p.y), bottom - 18) } |
| 2267 | else { bottom = max(min(bar.maxY, p.y), top + 18) } |
| 2268 | let f = (bottom - top) / max(1, bar.height) |
| 2269 | let newContentH = viewH / max(0.05, f) |
| 2270 | let k = newContentH / max(1, barDrag.origContentH) |
| 2271 | session.laneScale = barDrag.origScale * k |
| 2272 | if drag.mode == .vBarTop { |
| 2273 | scrollY = min(max(0, (barDrag.origScrollY + viewH) * k - viewH), maxScrollY) |
| 2274 | } else { |
| 2275 | scrollY = min(max(0, barDrag.origScrollY * k), maxScrollY) |
| 2276 | } |
| 2277 | case .box: |
| 2278 | let r = boxRect() |
| 2279 | var hit = drag.baseSelection |
| 2280 | rebuildSceneIfNeeded() |
| 2281 | for (row, ref) in laneRows.enumerated() { |
| 2282 | for clip in clipsByLane[ref] ?? [] |
| 2283 | where clipRect(clip, row: row).intersects(r) { |
| 2284 | hit.insert(clip.id) |
| 2285 | } |
| 2286 | } |
| 2287 | store.selection = hit |
| 2288 | case .resizeTrack: |
| 2289 | if let row = drag.resizeRow, let ref = laneRef(row: row) { |
| 2290 | let newH = drag.resizeOrigH + (p.y - drag.startPoint.y) |
| 2291 | let factor = newH / max(1, baseLaneH * session.laneScale) |
| 2292 | session.trackHeights[ref] = min(4, max(0.35, factor)) |
| 2293 | } |
| 2294 | } |
| 2295 | drag.moved = true |
| 2296 | autoscroll(with: event) |
| 2297 | needsDisplay = true |
| 2298 | } |
| 2299 | |
| 2300 | override func mouseUp(with event: NSEvent) { |
| 2301 | switch drag.mode { |
| 2302 | case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip, |
| 2303 | .stretchIn, .stretchOut, .fadeIn, .fadeOut: |
| 2304 | store.endGesture() |
| 2305 | default: |
| 2306 | break |
| 2307 | } |
| 2308 | // Click (no drag) on an already-selected clip collapses the multi- |
| 2309 | // selection down to just that clip. |
| 2310 | if !drag.moved, let id = drag.collapseTo { store.selection = [id] } |
| 2311 | drag = DragState() |
| 2312 | activeSnapTarget = nil |
| 2313 | dragHintRow = nil |
| 2314 | needsDisplay = true |
| 2315 | } |
| 2316 | |
| 2317 | // MARK: - Middle-mouse pan |
| 2318 | |
| 2319 | override func otherMouseDown(with event: NSEvent) { |
| 2320 | guard event.buttonNumber == 2 else { return } |
| 2321 | window?.makeFirstResponder(self) |
| 2322 | let p = convert(event.locationInWindow, from: nil) |
| 2323 | panDrag = PanDrag(active: true, start: p, |
| 2324 | origOrigin: originSecond, origScrollY: scrollY) |
| 2325 | } |
| 2326 | |
| 2327 | override func otherMouseDragged(with event: NSEvent) { |
| 2328 | guard panDrag.active else { return } |
| 2329 | let p = convert(event.locationInWindow, from: nil) |
| 2330 | originSecond = clampOrigin(panDrag.origOrigin |
| 2331 | - Double(p.x - panDrag.start.x) / pxPerSecond) |
| 2332 | if maxScrollY > 0 { |
| 2333 | scrollY = min(max(0, panDrag.origScrollY - (p.y - panDrag.start.y)), maxScrollY) |
| 2334 | } |
| 2335 | needsDisplay = true |
| 2336 | } |
| 2337 | |
| 2338 | override func otherMouseUp(with event: NSEvent) { |
| 2339 | guard event.buttonNumber == 2 else { return } |
| 2340 | panDrag.active = false |
| 2341 | } |
| 2342 | |
| 2343 | private func dragMove(p: NSPoint, dSec: Double) { |
| 2344 | guard let orig = drag.origClip else { return } |
| 2345 | // Vertical retracking only for a lone unlinked clip. |
| 2346 | let multi = drag.origSelection.count > 1 || store.selection.count > 1 |
| 2347 | |
| 2348 | // Snap+quantize the moved start edge, then clamp to t >= 0 for all moved clips. |
| 2349 | let snappedStart = snapAndQuantize(orig.start + dSec, |
| 2350 | excluding: Set(drag.origSelection.keys), |
| 2351 | duration: orig.duration) |
| 2352 | var delta = snappedStart - orig.start |
| 2353 | let minStart = drag.origSelection.values.map(\.start).min() ?? 0 |
| 2354 | if minStart + delta < 0 { delta = -minStart } |
| 2355 | |
| 2356 | // Vertical: retarget track of the grabbed clip only (single-clip |
| 2357 | // drags). Rows resolve against the GESTURE BASE — the closure below |
| 2358 | // rebuilds from it, so live-project indices (which may include ghost |
| 2359 | // tracks added by an earlier update in this same drag) would be off. |
| 2360 | let baseModel = store.gestureBaseModel ?? project |
| 2361 | let isPanel = orig.kind == .storyboard |
| 2362 | var targetRef: TrackRef? = nil |
| 2363 | var neededNewTracks = 0 |
| 2364 | var groupRowDelta = 0 |
| 2365 | if let row = rowAt(y: p.y) { |
| 2366 | let rows = baseModel.laneRefs |
| 2367 | let baseCount = rows.count |
| 2368 | if !multi { |
| 2369 | if row >= baseCount { |
| 2370 | // Dragging N rows below the last lane creates N tracks at |
| 2371 | // once; the clip lands on the deepest one. Storyboard panels |
| 2372 | // stay on THE storyboard track. |
| 2373 | if !isPanel { |
| 2374 | neededNewTracks = min(8, row - baseCount + 1) |
| 2375 | dragHintRow = row |
| 2376 | } |
| 2377 | } else { |
| 2378 | // Storyboard panels are isolated to the storyboard lane and |
| 2379 | // other clips stay off it. |
| 2380 | let target = rows[row] |
| 2381 | if (target == .storyboard) == isPanel { |
| 2382 | targetRef = target |
| 2383 | } |
| 2384 | } |
| 2385 | } else if row < baseCount, let origRow = baseModel.row(of: orig.track) { |
| 2386 | // Vertical GROUP move: the whole selection (link groups |
| 2387 | // included) shifts by the same number of rows, when every |
| 2388 | // destination row exists and is a plain video track. |
| 2389 | let rowDelta = row - origRow |
| 2390 | if rowDelta != 0 { |
| 2391 | let ok = drag.origSelection.values.allSatisfy { c in |
| 2392 | guard c.kind != .storyboard, |
| 2393 | let r = baseModel.row(of: c.track), |
| 2394 | r + rowDelta >= 0, r + rowDelta < baseCount |
| 2395 | else { return false } |
| 2396 | return rows[r + rowDelta].videoIndex != nil |
| 2397 | } |
| 2398 | if ok { groupRowDelta = rowDelta } |
| 2399 | } |
| 2400 | } |
| 2401 | } |
| 2402 | if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil { |
| 2403 | FileHandle.standardError.write(Data( |
| 2404 | ("dragMove p=\(p) row=\(rowAt(y: p.y).map(String.init) ?? "nil") " + |
| 2405 | "tracks=\(project.tracks.count) target=\(targetRef.map(\.wire) ?? "nil") " + |
| 2406 | "new=\(neededNewTracks) delta=\(delta)\n").utf8)) |
| 2407 | } |
| 2408 | |
| 2409 | store.updateGesture { model in |
| 2410 | for (id, o) in drag.origSelection { |
| 2411 | guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue } |
| 2412 | model.clips[i].start = o.start + delta |
| 2413 | } |
| 2414 | if groupRowDelta != 0 { |
| 2415 | let rows = model.laneRefs |
| 2416 | for (id, o) in drag.origSelection { |
| 2417 | guard let i = model.clips.firstIndex(where: { $0.id == id }), |
| 2418 | let r = model.row(of: o.track) |
| 2419 | else { continue } |
| 2420 | let nr = r + groupRowDelta |
| 2421 | if nr >= 0, nr < rows.count { |
| 2422 | model.clips[i].track = rows[nr] |
| 2423 | } |
| 2424 | } |
| 2425 | } |
| 2426 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } |
| 2427 | if neededNewTracks > 0 { |
| 2428 | // Indices are deterministic, so appending N lanes needs no |
| 2429 | // stable identities — the clip lands on the deepest new one. |
| 2430 | for _ in 0..<neededNewTracks { model.addTrack() } |
| 2431 | model.clips[i].track = .video(model.tracks.count - 1) |
| 2432 | } else if let ref = targetRef { |
| 2433 | model.clips[i].track = ref |
| 2434 | } |
| 2435 | } |
| 2436 | } |
| 2437 | |
| 2438 | /// A trim/slip drag is exposing source material the clip didn't use |
| 2439 | /// before — tell the proxy builder NOW (mid-drag), not at mouse-up, so |
| 2440 | /// the newly extended range is often already covered when the user plays |
| 2441 | /// it. `direction` is which way the exposure grows in source time. |
| 2442 | private func prefetchExposure(_ orig: Clip, sourceTime: Double, direction: Int) { |
| 2443 | guard orig.kind == .video, let media = project.media(orig.mediaId) else { return } |
| 2444 | ctx.chunks.noteGestureExposure(media: media, sourceTime: sourceTime, |
| 2445 | direction: direction) |
| 2446 | } |
| 2447 | |
| 2448 | /// Non-audio clips on the same track whose head would be swallowed by |
| 2449 | /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it |
| 2450 | /// never gets pushed). |
| 2451 | private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] { |
| 2452 | model.clips.filter { |
| 2453 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio |
| 2454 | && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9 |
| 2455 | } |
| 2456 | } |
| 2457 | |
| 2458 | private func dragTrimOut(dSec: Double) { |
| 2459 | guard let orig = drag.origClip else { return } |
| 2460 | // Storyboard panels are start-only: a panel "ends" where the next one |
| 2461 | // starts, so dragging the out edge really drags the NEXT panel's start. |
| 2462 | if orig.kind == .storyboard { |
| 2463 | let base = store.gestureBaseModel ?? project |
| 2464 | let panels = base.clips(on: orig.track).filter { $0.kind == .storyboard } |
| 2465 | guard let idx = panels.firstIndex(where: { $0.id == orig.id }), |
| 2466 | idx + 1 < panels.count else { return } // last panel: open-ended |
| 2467 | let next = panels[idx + 1] |
| 2468 | var desired = quantize(next.start + dSec) |
| 2469 | desired = max(desired, orig.start + frameDur) |
| 2470 | if idx + 2 < panels.count { |
| 2471 | desired = min(desired, panels[idx + 2].start - frameDur) |
| 2472 | } |
| 2473 | store.updateGesture { model in |
| 2474 | guard let i = model.clips.firstIndex(where: { $0.id == next.id }) else { return } |
| 2475 | model.clips[i].start = desired |
| 2476 | } |
| 2477 | return |
| 2478 | } |
| 2479 | let media = project.media(orig.mediaId) |
| 2480 | let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id]) |
| 2481 | var maxEnd = Double.greatestFiniteMagnitude |
| 2482 | if let media { |
| 2483 | maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed) |
| 2484 | } |
| 2485 | let base = store.gestureBaseModel ?? project |
| 2486 | var newEnd = min(max(desired, orig.start + frameDur), maxEnd) |
| 2487 | // Trimming through a neighbor auto-trims the neighbor's head, but a |
| 2488 | // clip can never be swallowed past its last frame. |
| 2489 | let victims = pushVictimsOut(orig: orig, newEnd: newEnd, model: base) |
| 2490 | for v in victims { newEnd = min(newEnd, v.end - frameDur) } |
| 2491 | newEnd = max(newEnd, orig.start + frameDur) |
| 2492 | // The grabbed clip's clamped change is the delta applied to every |
| 2493 | // selected/linked clip. |
| 2494 | let delta = newEnd - orig.end |
| 2495 | if delta > 0 { // extending the tail exposes fresh source material |
| 2496 | prefetchExposure(orig, sourceTime: orig.srcIn + (newEnd - orig.start) * orig.speed, |
| 2497 | direction: 1) |
| 2498 | } |
| 2499 | let targets = drag.origSelection.values.filter { $0.kind != .storyboard } |
| 2500 | let single = targets.count <= 1 |
| 2501 | store.updateGesture { model in |
| 2502 | for t in targets { |
| 2503 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } |
| 2504 | var end = t.end + delta |
| 2505 | if let m = model.media(t.mediaId) { |
| 2506 | end = min(end, t.start + (m.duration - t.srcIn) / max(0.001, t.speed)) |
| 2507 | } |
| 2508 | end = max(end, t.start + self.frameDur) |
| 2509 | model.clips[i].duration = end - t.start |
| 2510 | guard single else { continue } // neighbor auto-trim: single clip only |
| 2511 | for v in self.pushVictimsOut(orig: t, newEnd: end, model: model) |
| 2512 | where v.start < end { |
| 2513 | guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } |
| 2514 | let shift = end - v.start |
| 2515 | model.clips[j].srcIn = v.srcIn + shift * v.speed |
| 2516 | model.clips[j].start = end |
| 2517 | model.clips[j].duration = v.duration - shift |
| 2518 | } |
| 2519 | } |
| 2520 | } |
| 2521 | } |
| 2522 | |
| 2523 | private func dragTrimIn(dSec: Double) { |
| 2524 | guard let orig = drag.origClip else { return } |
| 2525 | let desired = snapAndQuantize(orig.start + dSec, excluding: [orig.id]) |
| 2526 | // Storyboard panels are pure start positions (no source media), so the |
| 2527 | // in-edge just slides the panel's start either way — bounded only by the |
| 2528 | // previous panel (a frame of clearance) and this panel's own end. |
| 2529 | if orig.kind == .storyboard { |
| 2530 | let base = store.gestureBaseModel ?? project |
| 2531 | let panels = base.clips(on: orig.track) |
| 2532 | .filter { $0.kind == .storyboard } |
| 2533 | .sorted { $0.start < $1.start } |
| 2534 | guard let idx = panels.firstIndex(where: { $0.id == orig.id }) else { return } |
| 2535 | let lower = idx > 0 ? panels[idx - 1].start + frameDur : 0 |
| 2536 | let newStart = min(max(desired, lower), orig.end - frameDur) |
| 2537 | store.updateGesture { model in |
| 2538 | guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return } |
| 2539 | model.clips[i].start = newStart |
| 2540 | model.clips[i].srcIn = 0 |
| 2541 | model.clips[i].duration = orig.end - newStart |
| 2542 | } |
| 2543 | return |
| 2544 | } |
| 2545 | var minStart = orig.start - orig.srcIn / max(0.001, orig.speed) |
| 2546 | minStart = max(0, minStart) |
| 2547 | let maxStart = orig.end - frameDur |
| 2548 | let newStart = min(max(desired, minStart), maxStart) |
| 2549 | if newStart < orig.start { // extending the head exposes earlier source |
| 2550 | prefetchExposure(orig, sourceTime: orig.srcIn + (newStart - orig.start) * orig.speed, |
| 2551 | direction: -1) |
| 2552 | } |
| 2553 | let base = store.gestureBaseModel ?? project |
| 2554 | // The grabbed clip's clamped change is the delta applied to every |
| 2555 | // selected/linked clip. A storyboard in-edge just moves that panel's |
| 2556 | // start (normalization re-derives its duration). |
| 2557 | let delta = newStart - orig.start |
| 2558 | let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard }) |
| 2559 | let single = targets.count <= 1 |
| 2560 | // Trimming back through the previous clip trims its tail (single only). |
| 2561 | let victims = base.clips.filter { |
| 2562 | $0.id != orig.id && $0.track == orig.track && $0.kind != .audio |
| 2563 | && $0.start < orig.start && $0.end > newStart |
| 2564 | } |
| 2565 | store.updateGesture { model in |
| 2566 | for t in targets { |
| 2567 | guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue } |
| 2568 | let tMin = max(0, t.start - t.srcIn / max(0.001, t.speed)) |
| 2569 | let s = min(max(t.start + delta, tMin), t.end - self.frameDur) |
| 2570 | model.clips[i].srcIn = t.srcIn + (s - t.start) * t.speed |
| 2571 | model.clips[i].start = s |
| 2572 | model.clips[i].duration = t.end - s |
| 2573 | } |
| 2574 | guard single else { return } |
| 2575 | for v in victims where v.end > newStart { |
| 2576 | guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } |
| 2577 | model.clips[j].duration = max(self.frameDur, newStart - v.start) |
| 2578 | } |
| 2579 | } |
| 2580 | } |
| 2581 | |
| 2582 | /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a |
| 2583 | /// normal trim, and everything after it on the same track shifts by the |
| 2584 | /// same amount ("pushing the rest away"). On the storyboard track this |
| 2585 | /// resizes a panel's slot while keeping the later panels' spacing. |
| 2586 | private func dragRippleOut(dSec: Double) { |
| 2587 | guard let orig = drag.origClip else { return } |
| 2588 | let base = store.gestureBaseModel ?? project |
| 2589 | let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id]) |
| 2590 | var newEnd = max(desired, orig.start + frameDur) |
| 2591 | if orig.kind == .video, let media = base.media(orig.mediaId) { |
| 2592 | newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)) |
| 2593 | } |
| 2594 | let delta = newEnd - orig.end |
| 2595 | let followers = base.clips.filter { |
| 2596 | $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9 |
| 2597 | } |
| 2598 | // Never push anything below t = 0. |
| 2599 | let minStart = followers.map(\.start).min() ?? 0 |
| 2600 | let clampedDelta = max(delta, -minStart) |
| 2601 | if clampedDelta > 0 { // ripple-extending the tail exposes fresh source |
| 2602 | prefetchExposure(orig, |
| 2603 | sourceTime: orig.srcIn + (orig.end + clampedDelta - orig.start) * orig.speed, |
| 2604 | direction: 1) |
| 2605 | } |
| 2606 | store.updateGesture { model in |
| 2607 | if orig.kind != .storyboard, |
| 2608 | let i = model.clips.firstIndex(where: { $0.id == orig.id }) { |
| 2609 | model.clips[i].duration = (orig.end + clampedDelta) - orig.start |
| 2610 | } |
| 2611 | for f in followers { |
| 2612 | guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue } |
| 2613 | model.clips[j].start = f.start + clampedDelta |
| 2614 | } |
| 2615 | } |
| 2616 | } |
| 2617 | |
| 2618 | /// ⌥-drag a clip's IN edge: ripple-trim the head while pinning the clip's |
| 2619 | /// start. The in-point (`srcIn`) and length change — |
| 2620 | /// the clip's OUT edge moves — and every later clip on the track shifts by the |
| 2621 | /// same amount so the timeline stays gapless. Unlike a normal trim-in (which |
| 2622 | /// slides the clip's start and opens blank space *before* it), the start stays |
| 2623 | /// put and the space is taken out of the track. Dragging right trims the head |
| 2624 | /// (clip shrinks, followers pull in); left extends it (clip grows, followers |
| 2625 | /// push out). The out edge — the source out-point — never moves. |
| 2626 | private func dragRippleIn(dSec: Double) { |
| 2627 | guard let orig = drag.origClip, orig.kind != .storyboard else { return } |
| 2628 | let base = store.gestureBaseModel ?? project |
| 2629 | let followers = base.clips.filter { |
| 2630 | $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9 |
| 2631 | } |
| 2632 | // Snap the moving OUT edge to static references only — the followers ride |
| 2633 | // along with it, so they can't be snap targets. d > 0 trims the head. |
| 2634 | var exclude = Set(followers.map(\.id)); exclude.insert(orig.id) |
| 2635 | let newEnd = snapAndQuantize(orig.end - dSec, excluding: exclude) |
| 2636 | var d = orig.end - newEnd |
| 2637 | d = min(d, orig.duration - frameDur) // keep ≥ one frame |
| 2638 | d = max(d, -orig.srcIn / max(0.001, orig.speed)) // head ≥ source start |
| 2639 | if let firstStart = followers.map(\.start).min() { // followers ≥ t = 0 |
| 2640 | d = min(d, firstStart) |
| 2641 | } |
| 2642 | if d < 0 { // ripple-extending the head exposes earlier source |
| 2643 | prefetchExposure(orig, sourceTime: orig.srcIn + d * orig.speed, direction: -1) |
| 2644 | } |
| 2645 | store.updateGesture { model in |
| 2646 | guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return } |
| 2647 | model.clips[i].srcIn = orig.srcIn + d * orig.speed |
| 2648 | model.clips[i].duration = orig.duration - d |
| 2649 | for f in followers { |
| 2650 | guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue } |
| 2651 | model.clips[j].start = f.start - d |
| 2652 | } |
| 2653 | } |
| 2654 | } |
| 2655 | |
| 2656 | private func dragSlip(dSec: Double) { |
| 2657 | guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return } |
| 2658 | let maxIn = max(0, media.duration - orig.sourceLength) |
| 2659 | let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn) |
| 2660 | // Slipping reveals source on the side the content is sliding from. |
| 2661 | if newIn < orig.srcIn { |
| 2662 | prefetchExposure(orig, sourceTime: newIn, direction: -1) |
| 2663 | } else if newIn > orig.srcIn { |
| 2664 | prefetchExposure(orig, sourceTime: newIn + orig.sourceLength, direction: 1) |
| 2665 | } |
| 2666 | store.updateGesture { model in |
| 2667 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } |
| 2668 | model.clips[i].srcIn = newIn |
| 2669 | } |
| 2670 | } |
| 2671 | |
| 2672 | /// Reaper-style time stretch: ⌘-drag a clip edge. The source range stays |
| 2673 | /// fixed; duration changes and speed compensates. |
| 2674 | private func dragStretch(dSec: Double, fromStart: Bool) { |
| 2675 | guard let orig = drag.origClip else { return } |
| 2676 | let srcLen = orig.sourceLength |
| 2677 | var newDur: Double |
| 2678 | var newStart = orig.start |
| 2679 | if fromStart { |
| 2680 | var desired = quantize(orig.start + dSec) |
| 2681 | desired = min(max(desired, 0), orig.end - frameDur) |
| 2682 | newStart = desired |
| 2683 | newDur = orig.end - desired |
| 2684 | } else { |
| 2685 | let desired = quantize(orig.end + dSec) |
| 2686 | newDur = max(frameDur, desired - orig.start) |
| 2687 | } |
| 2688 | newDur = min(max(newDur, srcLen / 50), srcLen * 50) |
| 2689 | if fromStart { newStart = orig.end - newDur } |
| 2690 | let speed = srcLen / newDur |
| 2691 | store.updateGesture { model in |
| 2692 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } |
| 2693 | model.clips[i].start = newStart |
| 2694 | model.clips[i].duration = newDur |
| 2695 | model.clips[i].speed = speed |
| 2696 | } |
| 2697 | } |
| 2698 | |
| 2699 | private func dragFade(p: NSPoint) { |
| 2700 | guard let orig = drag.origClip else { return } |
| 2701 | let sec = secondsFor(p.x) |
| 2702 | store.updateGesture { model in |
| 2703 | guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return } |
| 2704 | if drag.mode == .fadeIn { |
| 2705 | model.clips[i].fadeIn = min(max(sec - orig.start, 0), orig.duration) |
| 2706 | } else { |
| 2707 | model.clips[i].fadeOut = min(max(orig.end - sec, 0), orig.duration) |
| 2708 | } |
| 2709 | } |
| 2710 | } |
| 2711 | |
| 2712 | /// The single place edge alignment is decided: snap `desired` to nearby clip |
| 2713 | /// edges / playhead / 0 (unless snapping is off), record the snap indicator, |
| 2714 | /// then frame-quantize — snap FIRST, quantize ONCE. Every drag handler routes |
| 2715 | /// through here so trims, moves and ripples align identically (previously each |
| 2716 | /// re-implemented this, and the ripple handler quantized twice out of order). |
| 2717 | private func snapAndQuantize(_ desired: Double, excluding: Set<UUID>, |
| 2718 | duration: Double = 0) -> Double { |
| 2719 | var v = desired |
| 2720 | if let adj = snapAdjust(start: v, duration: duration, excluding: excluding) { |
| 2721 | v += adj.adjust |
| 2722 | activeSnapTarget = adj.target |
| 2723 | } |
| 2724 | return quantize(v) |
| 2725 | } |
| 2726 | |
| 2727 | private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>) |
| 2728 | -> (adjust: Double, target: Double)? { |
| 2729 | // Holding ⇧ mid-drag temporarily inverts snapping. |
| 2730 | let inverted = NSEvent.modifierFlags.contains(.shift) |
| 2731 | guard session.snapping != inverted else { return nil } |
| 2732 | let threshold = 8.0 / pxPerSecond |
| 2733 | var targets: [Double] = [0, playback.playhead] |
| 2734 | for c in project.clips where !excluding.contains(c.id) { |
| 2735 | targets.append(c.start) |
| 2736 | targets.append(c.end) |
| 2737 | } |
| 2738 | var best: (adjust: Double, target: Double)? |
| 2739 | let edges = duration > 0 ? [start, start + duration] : [start] |
| 2740 | for t in targets { |
| 2741 | for e in edges { |
| 2742 | let adj = t - e |
| 2743 | if abs(adj) < threshold, best == nil || abs(adj) < abs(best!.adjust) { |
| 2744 | best = (adj, t) |
| 2745 | } |
| 2746 | } |
| 2747 | } |
| 2748 | return best |
| 2749 | } |
| 2750 | |
| 2751 | // MARK: - Cursor & mouse tracking |
| 2752 | |
| 2753 | override func updateTrackingAreas() { |
| 2754 | super.updateTrackingAreas() |
| 2755 | trackingAreas.forEach(removeTrackingArea) |
| 2756 | addTrackingArea(NSTrackingArea( |
| 2757 | rect: bounds, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], |
| 2758 | owner: self, userInfo: nil)) |
| 2759 | } |
| 2760 | |
| 2761 | override func mouseMoved(with event: NSEvent) { |
| 2762 | lastMousePoint = convert(event.locationInWindow, from: nil) |
| 2763 | cursor(for: event.modifierFlags).set() |
| 2764 | } |
| 2765 | |
| 2766 | // Holding a modifier changes what a drag would do, so refresh the cursor even |
| 2767 | // when the pointer is still (mouseMoved won't fire on a bare key press). |
| 2768 | override func flagsChanged(with event: NSEvent) { |
| 2769 | cursor(for: event.modifierFlags).set() |
| 2770 | } |
| 2771 | |
| 2772 | /// The cursor for the pointer's current spot, given the held modifiers, so it |
| 2773 | /// previews the gesture a drag would start: ⌥ over a clip body slips it, ⌥/⌘ |
| 2774 | /// or a plain hover over an edge resizes (trim / ripple / stretch). |
| 2775 | private func cursor(for mods: NSEvent.ModifierFlags) -> NSCursor { |
| 2776 | let p = lastMousePoint |
| 2777 | if let barMode = scrollbarHit(p) { |
| 2778 | switch barMode { |
| 2779 | case .hBarLeft, .hBarRight: return .resizeLeftRight |
| 2780 | case .vBarTop, .vBarBottom: return .resizeUpDown |
| 2781 | default: return .arrow |
| 2782 | } |
| 2783 | } |
| 2784 | if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop { return .resizeUpDown } |
| 2785 | guard let (clip, row) = clipAt(point: p) else { return .arrow } |
| 2786 | if session.mainTool == .blade { return .crosshair } |
| 2787 | if session.mainTool == .slide { return .openHand } |
| 2788 | let rect = clipRect(clip, row: row) |
| 2789 | let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 7 |
| 2790 | if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip |
| 2791 | return onEdge ? .resizeLeftRight : .arrow |
| 2792 | } |
| 2793 | |
| 2794 | // MARK: - Keyboard (fallbacks; the menu bar owns the canonical bindings) |
| 2795 | |
| 2796 | override func keyDown(with event: NSEvent) { |
| 2797 | let pc = playback |
| 2798 | // A key pressed WHILE a mouse drag holds an open gesture must not run — |
| 2799 | // a mutating shortcut (split/delete/nudge) calls `store.mutate`, whose |
| 2800 | // `precondition(gestureBase == nil)` would trap mid-drag. Ignore keys |
| 2801 | // until the drag ends; Escape still cancels it. |
| 2802 | if store.gestureBaseModel != nil { |
| 2803 | if event.keyCode == 53 { cancelOperation(nil) } // Esc → cancel drag |
| 2804 | return |
| 2805 | } |
| 2806 | switch event.charactersIgnoringModifiers?.lowercased() { |
| 2807 | case " ": pc.togglePlay() |
| 2808 | case "j": pc.shuttle(-1) |
| 2809 | case "k": pc.setRate(0) |
| 2810 | case "l": pc.shuttle(1) |
| 2811 | case "s": split() |
| 2812 | case "n": toggleNewShot() |
| 2813 | case "i": pc.setIn() |
| 2814 | case "o": |
| 2815 | if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() } |
| 2816 | else { pc.setOut() } |
| 2817 | case "c": pc.toggleLoop() |
| 2818 | case "m": |
| 2819 | if event.modifierFlags.contains(.option) { toggleMute() } |
| 2820 | else { toggleMarkerAtPlayhead() } |
| 2821 | case "v": session.mainTool = .select |
| 2822 | case "y": session.snapping.toggle() |
| 2823 | case "g": |
| 2824 | if event.modifierFlags.contains(.option) { unlinkSelection() } |
| 2825 | else { linkSelection() } |
| 2826 | case "b": splitStoryboardAtPlayhead(newShot: event.modifierFlags.contains(.shift)) |
| 2827 | case "x": // X delete / ⇧X ripple (same as ⌫) |
| 2828 | if store.selection.isEmpty { |
| 2829 | closeBlankSpace(at: quantize(pc.playhead)) |
| 2830 | } else if event.modifierFlags.contains(.shift) { |
| 2831 | rippleDelete() |
| 2832 | } else { |
| 2833 | deleteSelection() |
| 2834 | } |
| 2835 | case "\\": rippleDelete() // documented default ripple-delete key (also ⌥⌫) |
| 2836 | case "a" where !event.modifierFlags.contains(.command): // ripple trim left (also ⌥←) |
| 2837 | rippleTrimToPlayhead(deleteLeft: true) |
| 2838 | case "d" where !event.modifierFlags.contains(.command): // ripple trim right (also ⌥→) |
| 2839 | rippleTrimToPlayhead(deleteLeft: false) |
| 2840 | case "[": |
| 2841 | if event.modifierFlags.contains(.option) { goToPrevMarker() } |
| 2842 | else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() } |
| 2843 | else { pc.step(by: -frameDur) } |
| 2844 | case "]": |
| 2845 | if event.modifierFlags.contains(.option) { goToNextMarker() } |
| 2846 | else if event.modifierFlags.contains(.command) { goToNextStoryboardPanel() } |
| 2847 | else { pc.step(by: frameDur) } |
| 2848 | default: |
| 2849 | switch event.keyCode { |
| 2850 | case 123 where event.modifierFlags.contains(.option): // ⌥← |
| 2851 | rippleTrimToPlayhead(deleteLeft: true) |
| 2852 | case 124 where event.modifierFlags.contains(.option): // ⌥→ |
| 2853 | rippleTrimToPlayhead(deleteLeft: false) |
| 2854 | case 123: nudgeSelection(by: event.modifierFlags.contains(.shift) ? -1 : -frameDur) // ← |
| 2855 | case 124: nudgeSelection(by: event.modifierFlags.contains(.shift) ? 1 : frameDur) // → |
| 2856 | case 53: cancelOperation(nil) // esc |
| 2857 | case 115: pc.seek(to: 0) // Home |
| 2858 | case 119: pc.seek(to: project.timelineDuration) // End |
| 2859 | case 51, 117: // ⌫, ⌦ |
| 2860 | if store.selection.isEmpty { |
| 2861 | closeBlankSpace(at: quantize(pc.playhead)) // "delete the space" |
| 2862 | } else if event.modifierFlags.contains(.option) { |
| 2863 | rippleDelete() |
| 2864 | } else { |
| 2865 | deleteSelection() |
| 2866 | } |
| 2867 | default: super.keyDown(with: event) |
| 2868 | } |
| 2869 | } |
| 2870 | } |
| 2871 | |
| 2872 | /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing |
| 2873 | /// selected they move the playhead like [ and ]. |
| 2874 | func nudgeSelection(by seconds: Double) { |
| 2875 | let sel = project.expandLinks(store.selection) |
| 2876 | guard !sel.isEmpty else { |
| 2877 | playback.step(by: seconds) |
| 2878 | return |
| 2879 | } |
| 2880 | store.mutate { model in |
| 2881 | let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0 |
| 2882 | let d = max(seconds, -minStart) |
| 2883 | for i in model.clips.indices where sel.contains(model.clips[i].id) { |
| 2884 | model.clips[i].start += d |
| 2885 | } |
| 2886 | } |
| 2887 | } |
| 2888 | |
| 2889 | // MARK: - Clip actions |
| 2890 | |
| 2891 | func toggleMute() { |
| 2892 | var ids = store.selection |
| 2893 | if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] } |
| 2894 | guard !ids.isEmpty else { return } |
| 2895 | store.mutate { model in |
| 2896 | let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted) |
| 2897 | for i in model.clips.indices where ids.contains(model.clips[i].id) { |
| 2898 | model.clips[i].muted = !allMuted |
| 2899 | } |
| 2900 | } |
| 2901 | } |
| 2902 | |
| 2903 | /// S — Reaper-style split at the playhead. When the playhead sits INSIDE |
| 2904 | /// a red overlap involving the selection (or any overlap when nothing is |
| 2905 | /// selected), the split resolves it: the earlier clip's out point and the |
| 2906 | /// later clip's in point meet at the playhead. Anywhere else it's a |
| 2907 | /// normal split (overlap left alone). Storyboard panels split by |
| 2908 | /// duplicating the drawing (the split time becomes the new panel's time). |
| 2909 | func split() { |
| 2910 | let t = quantize(playback.playhead) |
| 2911 | let sel = store.selection |
| 2912 | let atPlayhead = project.overlaps().filter { o in |
| 2913 | t > o.start + 1e-9 && t < o.end - 1e-9 |
| 2914 | && (sel.isEmpty || sel.contains(o.a.id) || sel.contains(o.b.id)) |
| 2915 | } |
| 2916 | if !atPlayhead.isEmpty { |
| 2917 | let fd = frameDur |
| 2918 | store.mutate { model in |
| 2919 | for o in atPlayhead { |
| 2920 | if let i = model.clips.firstIndex(where: { $0.id == o.a.id }) { |
| 2921 | model.clips[i].duration = max(fd, t - model.clips[i].start) |
| 2922 | } |
| 2923 | if let i = model.clips.firstIndex(where: { $0.id == o.b.id }) { |
| 2924 | let shift = t - model.clips[i].start |
| 2925 | if shift > 0, shift < model.clips[i].duration - fd / 2 { |
| 2926 | model.clips[i].srcIn += shift * model.clips[i].speed |
| 2927 | model.clips[i].start = t |
| 2928 | model.clips[i].duration -= shift |
| 2929 | } |
| 2930 | } |
| 2931 | } |
| 2932 | } |
| 2933 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 2934 | userInfo: ["text": "Split resolved \(atPlayhead.count) overlap\(atPlayhead.count == 1 ? "" : "s") at the playhead"]) |
| 2935 | return |
| 2936 | } |
| 2937 | bladeAtPlayhead(rightBoard: { board in boards.duplicate(board) }) |
| 2938 | } |
| 2939 | |
| 2940 | /// B — split the storyboard panel under the playhead into two, regardless |
| 2941 | /// of the selection; the new (right) panel inherits the drawing and becomes |
| 2942 | /// the selection. ⇧B (newShot) also flags that new panel as a new shot. |
| 2943 | /// With no panel under the playhead there's nothing to cut, so a fresh one |
| 2944 | /// is dropped there instead — so B always advances the storyboard. |
| 2945 | func splitStoryboardAtPlayhead(newShot: Bool = false) { |
| 2946 | let t = quantize(playback.playhead) |
| 2947 | guard let panel = project.clipAt(track: .storyboard, time: t, kind: .storyboard), |
| 2948 | t > panel.start + frameDur / 2, t < panel.end - frameDur / 2 |
| 2949 | else { |
| 2950 | addStoryboardPanel(at: t) |
| 2951 | if newShot { toggleNewShot() } |
| 2952 | return |
| 2953 | } |
| 2954 | let rightBoard = panel.board.map { boards.duplicate($0) } |
| 2955 | ?? project.newBoard() |
| 2956 | var newId: UUID? |
| 2957 | store.mutate { model in |
| 2958 | guard let i = model.clips.firstIndex(where: { $0.id == panel.id }) else { return } |
| 2959 | var right = panel |
| 2960 | right.id = UUID() |
| 2961 | right.start = t |
| 2962 | right.srcIn = 0 |
| 2963 | right.duration = panel.end - t |
| 2964 | right.board = rightBoard |
| 2965 | right.newShot = newShot |
| 2966 | model.clips[i].duration = t - panel.start |
| 2967 | model.clips.append(right) |
| 2968 | newId = right.id |
| 2969 | } |
| 2970 | if let id = newId { store.selection = [id] } |
| 2971 | } |
| 2972 | |
| 2973 | /// N — toggle the "new shot" marker on the targeted storyboard panel(s): |
| 2974 | /// the selection if it holds any panels, else the panel under the playhead. |
| 2975 | func toggleNewShot() { |
| 2976 | let ids = newShotTargets() |
| 2977 | guard !ids.isEmpty else { return } |
| 2978 | store.mutate { model in |
| 2979 | // If any target isn't a new shot yet, turn them all on; else clear. |
| 2980 | let turnOn = ids.contains { id in |
| 2981 | model.clips.first(where: { $0.id == id })?.newShot == false |
| 2982 | } |
| 2983 | for i in model.clips.indices where ids.contains(model.clips[i].id) { |
| 2984 | model.clips[i].newShot = turnOn |
| 2985 | } |
| 2986 | } |
| 2987 | } |
| 2988 | |
| 2989 | /// Storyboard panels the new-shot toggle acts on: selected panels, or — |
| 2990 | /// when nothing storyboard is selected — the panel under the playhead. |
| 2991 | func newShotTargets() -> Set<UUID> { |
| 2992 | var ids = store.selection.filter { project.clip($0)?.kind == .storyboard } |
| 2993 | if ids.isEmpty, |
| 2994 | let panel = project.clipAt(track: .storyboard, |
| 2995 | time: playback.playhead, |
| 2996 | kind: .storyboard) { |
| 2997 | ids = [panel.id] |
| 2998 | } |
| 2999 | return ids |
| 3000 | } |
| 3001 | |
| 3002 | /// For the menu checkbox: nil = no storyboard panel is targeted; otherwise |
| 3003 | /// whether every targeted panel is already flagged a new shot. |
| 3004 | var newShotMenuState: Bool? { |
| 3005 | let ids = newShotTargets() |
| 3006 | guard !ids.isEmpty else { return nil } |
| 3007 | return ids.allSatisfy { project.clip($0)?.newShot == true } |
| 3008 | } |
| 3009 | |
| 3010 | func bladeAtPlayhead(at time: Double? = nil, ids explicitIds: Set<UUID>? = nil, |
| 3011 | onlyStoryboards: Bool = false, newShot: Bool = false, |
| 3012 | rightBoard: (Board) -> Board) { |
| 3013 | let t = time ?? quantize(playback.playhead) |
| 3014 | var ids = explicitIds ?? store.selection |
| 3015 | // With nothing selected, S blades everything the playhead crosses — but |
| 3016 | // NOT storyboard panels (those only split via an explicit selection or |
| 3017 | // the N key, which passes onlyStoryboards). |
| 3018 | if ids.isEmpty { |
| 3019 | ids = Set(project.clips |
| 3020 | .filter { onlyStoryboards || $0.kind != .storyboard } |
| 3021 | .map(\.id)) |
| 3022 | } |
| 3023 | ids = project.expandLinks(ids) |
| 3024 | let victims = project.clips.filter { |
| 3025 | ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2 |
| 3026 | && (!onlyStoryboards || $0.kind == .storyboard) |
| 3027 | } |
| 3028 | guard !victims.isEmpty else { return } |
| 3029 | // When the split acted on an actual selection, both resulting halves |
| 3030 | // stay selected. (The blade tool passes explicitIds and leaves the |
| 3031 | // selection alone.) |
| 3032 | let reselect = explicitIds == nil && !store.selection.isEmpty |
| 3033 | var newRightIds: [UUID] = [] |
| 3034 | var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId |
| 3035 | // Board copies happen OUTSIDE mutate (they touch disk). |
| 3036 | var rightBoards: [UUID: Board] = [:] |
| 3037 | for v in victims where v.kind == .storyboard { |
| 3038 | if let board = v.board { rightBoards[v.id] = rightBoard(board) } |
| 3039 | } |
| 3040 | store.mutate { model in |
| 3041 | for v in victims { |
| 3042 | guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue } |
| 3043 | var right = v |
| 3044 | right.id = UUID() |
| 3045 | newRightIds.append(right.id) |
| 3046 | right.start = t |
| 3047 | right.srcIn = v.srcIn + (t - v.start) * v.speed |
| 3048 | right.duration = v.end - t |
| 3049 | if let link = v.linkId { |
| 3050 | if rightLinkIds[link] == nil { rightLinkIds[link] = UUID() } |
| 3051 | right.linkId = rightLinkIds[link] |
| 3052 | } |
| 3053 | if v.kind == .storyboard { |
| 3054 | right.srcIn = 0 |
| 3055 | right.board = rightBoards[v.id] |
| 3056 | right.newShot = newShot |
| 3057 | } |
| 3058 | // Fades stay on their outer edges; the cut itself is clean. |
| 3059 | if v.kind == .audio { |
| 3060 | right.fadeIn = 0 |
| 3061 | right.fadeOut = min(v.fadeOut, right.duration) |
| 3062 | } |
| 3063 | model.clips[i].duration = t - v.start |
| 3064 | if v.kind == .audio { |
| 3065 | model.clips[i].fadeOut = 0 |
| 3066 | model.clips[i].fadeIn = min(v.fadeIn, t - v.start) |
| 3067 | } |
| 3068 | model.clips.append(right) |
| 3069 | } |
| 3070 | } |
| 3071 | if reselect { |
| 3072 | store.selection = Set(victims.map(\.id)).union(newRightIds) |
| 3073 | } |
| 3074 | } |
| 3075 | |
| 3076 | /// O — move overlapping clips apart: the later clip of each overlap goes |
| 3077 | /// to another track with room (that's what separate tracks are for), |
| 3078 | /// creating one when needed. Splitting at the playhead (S) is the other |
| 3079 | /// way to resolve. |
| 3080 | func moveOverlapsToSeparateTracks() { |
| 3081 | let before = project.overlaps().count |
| 3082 | guard before > 0 else { |
| 3083 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3084 | userInfo: ["text": "No overlaps to move"]) |
| 3085 | return |
| 3086 | } |
| 3087 | store.mutate { model in |
| 3088 | var guardCount = 0 |
| 3089 | while guardCount < 100 { |
| 3090 | guardCount += 1 |
| 3091 | guard let o = model.overlaps().first, |
| 3092 | let bi = model.clips.firstIndex(where: { $0.id == o.b.id }) |
| 3093 | else { break } |
| 3094 | let b = model.clips[bi] |
| 3095 | if b.kind == .storyboard { // panels can't truly overlap; park on the lane |
| 3096 | model.clips[bi].track = .storyboard |
| 3097 | continue |
| 3098 | } |
| 3099 | // A video lane with no conflicting clip, else a fresh one. |
| 3100 | let targetIndex = model.tracks.indices.first(where: { idx in |
| 3101 | TrackRef.video(idx) != b.track |
| 3102 | && !model.clips.contains { |
| 3103 | $0.id != b.id && $0.track == .video(idx) && $0.kind != .audio |
| 3104 | && $0.start < b.end && $0.end > b.start |
| 3105 | } |
| 3106 | }) ?? model.addTrack() |
| 3107 | model.clips[bi].track = .video(targetIndex) |
| 3108 | } |
| 3109 | } |
| 3110 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3111 | userInfo: ["text": "Moved \(before) overlap\(before == 1 ? "" : "s") to separate tracks"]) |
| 3112 | } |
| 3113 | |
| 3114 | func linkSelection() { |
| 3115 | let ids = store.selection |
| 3116 | guard ids.count > 1 else { return } |
| 3117 | let link = UUID() |
| 3118 | store.mutate { model in |
| 3119 | for i in model.clips.indices where ids.contains(model.clips[i].id) { |
| 3120 | model.clips[i].linkId = link |
| 3121 | } |
| 3122 | } |
| 3123 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3124 | userInfo: ["text": "Linked \(ids.count) clips"]) |
| 3125 | } |
| 3126 | |
| 3127 | func unlinkSelection() { |
| 3128 | let ids = project.expandLinks(store.selection) |
| 3129 | guard !ids.isEmpty else { return } |
| 3130 | store.mutate { model in |
| 3131 | for i in model.clips.indices where ids.contains(model.clips[i].id) { |
| 3132 | model.clips[i].linkId = nil |
| 3133 | } |
| 3134 | } |
| 3135 | } |
| 3136 | |
| 3137 | func deleteSelection() { |
| 3138 | // Deleting a linked clip deletes its whole link group. |
| 3139 | let sel = project.expandLinks(store.selection) |
| 3140 | guard !sel.isEmpty else { return } |
| 3141 | store.mutate { model in |
| 3142 | model.clips.removeAll { sel.contains($0.id) } |
| 3143 | model.pruneTrailingEmptyTracks() |
| 3144 | } |
| 3145 | } |
| 3146 | |
| 3147 | /// ⌥⌫ — ripple delete: remove the selection and close the time gap it |
| 3148 | /// occupied, shifting everything later (on ALL tracks, so multicam sync |
| 3149 | /// holds) left by the gap. |
| 3150 | func rippleDelete() { |
| 3151 | let sel = project.expandLinks(store.selection) |
| 3152 | guard !sel.isEmpty else { return } |
| 3153 | let victims = project.clips.filter { sel.contains($0.id) } |
| 3154 | // Storyboard panels are start-only points; only solid clips define |
| 3155 | // the range that closes. |
| 3156 | let solid = victims.filter { $0.kind != .storyboard } |
| 3157 | let start = solid.map(\.start).min() |
| 3158 | let end = solid.map(\.end).max() |
| 3159 | store.mutate { model in |
| 3160 | model.clips.removeAll { sel.contains($0.id) } |
| 3161 | if let start, let end, end > start { |
| 3162 | let gap = end - start |
| 3163 | for i in model.clips.indices where model.clips[i].start >= end - 1e-9 { |
| 3164 | model.clips[i].start = max(0, model.clips[i].start - gap) |
| 3165 | } |
| 3166 | } |
| 3167 | model.pruneTrailingEmptyTracks() |
| 3168 | } |
| 3169 | // Keep the cursor on the same logical content rather than jumping it to |
| 3170 | // the cut. If it sat after the removed span, slide it left by the gap so |
| 3171 | // the exact frame under it is unchanged — a playing stream keeps rolling |
| 3172 | // with no reseek. Inside the span, land on the close point; before it, |
| 3173 | // leave it be. |
| 3174 | if let start, let end, end > start { |
| 3175 | let ph = playback.playhead |
| 3176 | if ph >= end - 1e-9 { |
| 3177 | playback.shift(by: -(end - start)) |
| 3178 | } else if ph > start { |
| 3179 | playback.seek(to: start) |
| 3180 | } |
| 3181 | } |
| 3182 | } |
| 3183 | |
| 3184 | /// ⌥← / ⌥→ — split the clip(s) under the playhead and ripple-delete the |
| 3185 | /// side toward the arrow, closing the gap. With a selection it acts on the |
| 3186 | /// selected/linked clips the playhead crosses; with none, on every angle |
| 3187 | /// under the playhead (so multicam stays in sync). |
| 3188 | func rippleTrimToPlayhead(deleteLeft: Bool) { |
| 3189 | let t = quantize(playback.playhead) |
| 3190 | let eps = 1e-6 |
| 3191 | let straddles: (Clip) -> Bool = { |
| 3192 | $0.kind != .storyboard && $0.start + eps < t && $0.end - eps > t |
| 3193 | } |
| 3194 | let sel = project.expandLinks(store.selection) |
| 3195 | let hadSelection = !store.selection.isEmpty |
| 3196 | // Prefer the selected/linked clips the playhead crosses; fall back to |
| 3197 | // every angle under it (so multicam stays in sync). |
| 3198 | func pick(_ p: @escaping (Clip) -> Bool) -> [Clip] { |
| 3199 | let inSel = project.clips.filter { sel.contains($0.id) && p($0) } |
| 3200 | return inSel.isEmpty ? project.clips.filter(p) : inSel |
| 3201 | } |
| 3202 | |
| 3203 | let crossing = pick(straddles) |
| 3204 | if !crossing.isEmpty { |
| 3205 | let gapStart = deleteLeft ? crossing.map(\.start).min()! : t |
| 3206 | let gapEnd = deleteLeft ? t : crossing.map(\.end).max()! |
| 3207 | let gap = gapEnd - gapStart |
| 3208 | guard gap > eps else { return } |
| 3209 | let ids = Set(crossing.map(\.id)) |
| 3210 | store.mutate { model in |
| 3211 | for tgt in model.clips where ids.contains(tgt.id) { |
| 3212 | guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue } |
| 3213 | if deleteLeft { |
| 3214 | model.clips[i].srcIn = tgt.srcIn + (t - tgt.start) * tgt.speed |
| 3215 | model.clips[i].start = t |
| 3216 | model.clips[i].duration = tgt.end - t |
| 3217 | } else { |
| 3218 | model.clips[i].duration = t - tgt.start |
| 3219 | } |
| 3220 | } |
| 3221 | // Close the gap on EVERY track (the kept right pieces start at |
| 3222 | // gapEnd, so they ride left with everything else). |
| 3223 | for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 { |
| 3224 | model.clips[i].start = max(0, model.clips[i].start - gap) |
| 3225 | } |
| 3226 | model.pruneTrailingEmptyTracks() |
| 3227 | } |
| 3228 | // The kept piece keeps its id, so a trim that started from a |
| 3229 | // selection leaves that resulting clip selected. |
| 3230 | if hadSelection { store.selection = ids } |
| 3231 | playback.seek(to: gapStart) |
| 3232 | return |
| 3233 | } |
| 3234 | |
| 3235 | // Nothing straddles the playhead — it's sitting exactly on a cut (e.g. |
| 3236 | // right after S). There's nothing to split, so ripple-delete the whole |
| 3237 | // clip butting the playhead on the side being trimmed, closing the gap. |
| 3238 | let abuts: (Clip) -> Bool = deleteLeft |
| 3239 | ? { $0.kind != .storyboard && abs($0.end - t) < eps } |
| 3240 | : { $0.kind != .storyboard && abs($0.start - t) < eps } |
| 3241 | let adjacent = pick(abuts) |
| 3242 | guard !adjacent.isEmpty else { return } |
| 3243 | let gapStart = adjacent.map(\.start).min()! |
| 3244 | let gapEnd = adjacent.map(\.end).max()! |
| 3245 | let gap = gapEnd - gapStart |
| 3246 | guard gap > eps else { return } |
| 3247 | let ids = Set(adjacent.map(\.id)) |
| 3248 | store.mutate { model in |
| 3249 | model.clips.removeAll { ids.contains($0.id) } |
| 3250 | for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 { |
| 3251 | model.clips[i].start = max(0, model.clips[i].start - gap) |
| 3252 | } |
| 3253 | model.pruneTrailingEmptyTracks() |
| 3254 | } |
| 3255 | playback.seek(to: gapStart) |
| 3256 | } |
| 3257 | |
| 3258 | /// The closeable blank column at a moment: nothing under it on any track, |
| 3259 | /// bounded by the previous content end and the next content start. |
| 3260 | func blankGap(at time: Double) -> (start: Double, end: Double)? { |
| 3261 | let solids = project.clips.filter { $0.kind != .storyboard } |
| 3262 | guard !solids.contains(where: { $0.start - 1e-6 < time && $0.end - 1e-6 > time }) |
| 3263 | else { return nil } // something is under the playhead — not blank |
| 3264 | let start = solids.filter { $0.end <= time + 1e-6 }.map(\.end).max() ?? 0 |
| 3265 | guard let end = solids.filter({ $0.start > time - 1e-6 }).map(\.start).min(), |
| 3266 | end - start > 1e-6 else { return nil } // trailing/zero blank |
| 3267 | return (start, end) |
| 3268 | } |
| 3269 | |
| 3270 | func closeBlankSpaceAtPlayhead() { |
| 3271 | closeBlankSpace(at: quantize(playback.playhead)) |
| 3272 | } |
| 3273 | |
| 3274 | /// "Delete the space": ripple the blank column at `time` closed on every |
| 3275 | /// track. Bound to ⌫/⌥⌫ with no selection, and the empty-lane menu. |
| 3276 | func closeBlankSpace(at time: Double) { |
| 3277 | guard let g = blankGap(at: time) else { return } |
| 3278 | let gap = g.end - g.start |
| 3279 | store.mutate { model in |
| 3280 | for i in model.clips.indices where model.clips[i].start >= g.end - 1e-9 { |
| 3281 | model.clips[i].start = max(0, model.clips[i].start - gap) |
| 3282 | } |
| 3283 | model.pruneTrailingEmptyTracks() |
| 3284 | } |
| 3285 | playback.seek(to: g.start) |
| 3286 | } |
| 3287 | |
| 3288 | /// Esc — drop the selection and stop playback (cancels an open drag too). |
| 3289 | override func cancelOperation(_ sender: Any?) { |
| 3290 | if store.gestureBaseModel != nil { store.cancelGesture() } |
| 3291 | store.selection = [] |
| 3292 | // Escape during playback rewinds to where playback started; otherwise |
| 3293 | // it's a plain stop. |
| 3294 | if !playback.stopAndRevert() { playback.setRate(0) } |
| 3295 | needsDisplay = true |
| 3296 | } |
| 3297 | |
| 3298 | // MARK: - Responder-chain edit commands (menu Cut/Copy/Paste/Select All) |
| 3299 | |
| 3300 | override func selectAll(_ sender: Any?) { |
| 3301 | store.selection = Set(project.clips.map(\.id)) |
| 3302 | } |
| 3303 | |
| 3304 | private var selectedStoryboardClip: Clip? { |
| 3305 | project.clips.first { store.selection.contains($0.id) && $0.kind == .storyboard } |
| 3306 | } |
| 3307 | |
| 3308 | static let clipsPasteboardType = NSPasteboard.PasteboardType("com.sequencer.clips") |
| 3309 | |
| 3310 | private struct ClipsTransfer: Codable { |
| 3311 | var fps: Double |
| 3312 | var clips: [Clip] |
| 3313 | var media: [MediaItem] |
| 3314 | var rasters: [UUID: Data] // boardId → raster PNG for storyboard panels |
| 3315 | } |
| 3316 | |
| 3317 | /// ⌘C — copying clips IS copying for Fusion: the text on the pasteboard |
| 3318 | /// is Loader Lua (paste straight into the Flow view). A full clip payload |
| 3319 | /// rides along for ⌘V back into Sequencer, and a lone storyboard panel |
| 3320 | /// also puts its flattened PNG up for other apps. |
| 3321 | @objc func copy(_ sender: Any?) { |
| 3322 | let sel = project.expandLinks(store.selection) |
| 3323 | let clips = project.clips.filter { sel.contains($0.id) } |
| 3324 | .sorted { $0.start < $1.start } |
| 3325 | guard !clips.isEmpty else { |
| 3326 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3327 | userInfo: ["text": "Nothing selected to copy"]) |
| 3328 | return |
| 3329 | } |
| 3330 | let pb = NSPasteboard.general |
| 3331 | pb.clearContents() |
| 3332 | let loaders = clips.filter { $0.kind == .video && $0.mediaId != nil } |
| 3333 | if !loaders.isEmpty { |
| 3334 | pb.setString(FusionExport.loaderLua(for: loaders, project: project), |
| 3335 | forType: .string) |
| 3336 | } |
| 3337 | var rasters: [UUID: Data] = [:] |
| 3338 | for c in clips where c.kind == .storyboard { |
| 3339 | if let b = c.board, let png = boards.rasterPNGData(b.id) { |
| 3340 | rasters[b.id] = png |
| 3341 | } |
| 3342 | } |
| 3343 | let mediaIds = Set(clips.compactMap(\.mediaId)) |
| 3344 | let transfer = ClipsTransfer(fps: project.fps, clips: clips, |
| 3345 | media: project.media.filter { mediaIds.contains($0.id) }, |
| 3346 | rasters: rasters) |
| 3347 | if let data = try? JSONEncoder().encode(transfer) { |
| 3348 | pb.setData(data, forType: Self.clipsPasteboardType) |
| 3349 | } |
| 3350 | if clips.count == 1, clips[0].kind == .storyboard, let board = clips[0].board { |
| 3351 | let composite = boards.composite(for: board) |
| 3352 | if let tiff = composite.tiffRepresentation, |
| 3353 | let rep = NSBitmapImageRep(data: tiff), |
| 3354 | let png = rep.representation(using: .png, properties: [:]) { |
| 3355 | pb.setData(png, forType: .png) |
| 3356 | } |
| 3357 | } |
| 3358 | let what = "Copied \(clips.count) clip\(clips.count == 1 ? "" : "s")" |
| 3359 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3360 | userInfo: ["text": what]) |
| 3361 | } |
| 3362 | |
| 3363 | /// ⌘V — clips on the pasteboard land at the playhead (relative offsets |
| 3364 | /// kept, original tracks when they still exist). Otherwise an image or |
| 3365 | /// panel pastes INTO the selected storyboard panel as before. |
| 3366 | @objc func paste(_ sender: Any?) { |
| 3367 | let pb = NSPasteboard.general |
| 3368 | if let data = pb.data(forType: Self.clipsPasteboardType), |
| 3369 | let transfer = try? JSONDecoder().decode(ClipsTransfer.self, from: data) { |
| 3370 | pasteClips(transfer) |
| 3371 | return |
| 3372 | } |
| 3373 | guard let clip = selectedStoryboardClip else { |
| 3374 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3375 | userInfo: ["text": "Clipboard has no clips — select a storyboard panel to paste an image into"]) |
| 3376 | return |
| 3377 | } |
| 3378 | let size = clip.board?.size ?? CGSize(width: 1600, height: 900) |
| 3379 | guard let board = boards.panelFromPasteboard(size: size) else { |
| 3380 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3381 | userInfo: ["text": "Clipboard has no panel or image"]) |
| 3382 | return |
| 3383 | } |
| 3384 | store.mutate { model in |
| 3385 | guard let i = model.clips.firstIndex(where: { $0.id == clip.id }) else { return } |
| 3386 | model.clips[i].board = board |
| 3387 | } |
| 3388 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3389 | userInfo: ["text": "Pasted panel"]) |
| 3390 | } |
| 3391 | |
| 3392 | private func pasteClips(_ t: ClipsTransfer) { |
| 3393 | let t0 = t.clips.map(\.start).min() ?? 0 |
| 3394 | let offset = quantize(playback.playhead - t0) |
| 3395 | var newIds: Set<UUID> = [] |
| 3396 | store.mutate { model in |
| 3397 | var mediaMap: [UUID: UUID] = [:] |
| 3398 | for m in t.media { |
| 3399 | if let existing = model.media.first(where: { |
| 3400 | ($0.cacheKey == m.cacheKey && !m.cacheKey.isEmpty) || $0.id == m.id |
| 3401 | }) { |
| 3402 | mediaMap[m.id] = existing.id |
| 3403 | } else { |
| 3404 | model.media.append(m) |
| 3405 | mediaMap[m.id] = m.id |
| 3406 | } |
| 3407 | } |
| 3408 | var linkMap: [UUID: UUID] = [:] |
| 3409 | var trackMap: [Int: Int] = [:] // source video lane → lane in this project |
| 3410 | for var c in t.clips { |
| 3411 | let oldBoardId = c.board?.id |
| 3412 | c.id = UUID() |
| 3413 | c.start = max(0, c.start + offset) |
| 3414 | if let mid = c.mediaId { c.mediaId = mediaMap[mid] } |
| 3415 | if let l = c.linkId { |
| 3416 | if linkMap[l] == nil { linkMap[l] = UUID() } |
| 3417 | c.linkId = linkMap[l] |
| 3418 | } |
| 3419 | if c.kind == .storyboard { |
| 3420 | var b = c.board ?? model.newBoard() |
| 3421 | b.id = UUID() |
| 3422 | b.revision = 0 |
| 3423 | if let old = oldBoardId, let png = t.rasters[old] { |
| 3424 | boards.setRaster(fromPNG: png, boardId: b.id) |
| 3425 | } |
| 3426 | c.board = b |
| 3427 | c.track = .storyboard |
| 3428 | } else if let vi = c.track.videoIndex, !model.tracks.indices.contains(vi) { |
| 3429 | // Source lane this project doesn't have — make one (deduped). |
| 3430 | if trackMap[vi] == nil { trackMap[vi] = model.addTrack() } |
| 3431 | c.track = .video(trackMap[vi]!) |
| 3432 | } |
| 3433 | newIds.insert(c.id) |
| 3434 | model.clips.append(c) |
| 3435 | } |
| 3436 | } |
| 3437 | store.selection = newIds |
| 3438 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3439 | userInfo: ["text": "Pasted \(newIds.count) clip\(newIds.count == 1 ? "" : "s") at the playhead"]) |
| 3440 | } |
| 3441 | |
| 3442 | @objc func cut(_ sender: Any?) { |
| 3443 | copy(sender) |
| 3444 | deleteSelection() |
| 3445 | } |
| 3446 | |
| 3447 | // MARK: - Track actions |
| 3448 | |
| 3449 | func resetTrackVisibility() { |
| 3450 | session.hiddenTracks = [] |
| 3451 | session.focusedTracks = [] |
| 3452 | session.fusionHidden = false |
| 3453 | session.fusionFocus = false |
| 3454 | session.priorityPane = nil |
| 3455 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3456 | userInfo: ["text": "All tracks visible"]) |
| 3457 | } |
| 3458 | |
| 3459 | func deleteEmptyTracks() { |
| 3460 | store.mutate { $0.pruneEmptyTracks() } |
| 3461 | } |
| 3462 | |
| 3463 | /// New storyboard panel at the playhead on THE storyboard track |
| 3464 | /// (created on demand — panels are isolated to it). |
| 3465 | func addStoryboardPanel(at time: Double? = nil) { |
| 3466 | var t = quantize(time ?? playback.playhead) |
| 3467 | // Don't stack a new panel on top of one already starting here: bump it a |
| 3468 | // second into the future, or halfway to the next panel if one sits |
| 3469 | // within that second. |
| 3470 | do { |
| 3471 | let starts = project.clips |
| 3472 | .filter { $0.kind == .storyboard } |
| 3473 | .map(\.start) |
| 3474 | if starts.contains(where: { abs($0 - t) < frameDur / 2 }) { |
| 3475 | let next = starts.filter { $0 > t + frameDur / 2 }.min() |
| 3476 | // A panel exactly a second out still collides if we land on it, |
| 3477 | // so treat "within a second" inclusively and split the gap. |
| 3478 | if let next, next < t + 1 + frameDur / 2 { |
| 3479 | t = quantize((t + next) / 2) |
| 3480 | } else { |
| 3481 | t = quantize(t + 1) |
| 3482 | } |
| 3483 | } |
| 3484 | } |
| 3485 | var newClipId: UUID? |
| 3486 | store.mutate { model in |
| 3487 | let clip = Clip(mediaId: nil, track: .storyboard, start: t, srcIn: 0, |
| 3488 | duration: 3, kind: .storyboard, board: model.newBoard()) |
| 3489 | newClipId = clip.id |
| 3490 | model.clips.append(clip) |
| 3491 | } |
| 3492 | if let id = newClipId { |
| 3493 | store.selection = [id] |
| 3494 | } |
| 3495 | // Move the playhead onto the new panel so mashing B keeps stepping |
| 3496 | // forward a panel at a time. |
| 3497 | playback.seek(to: t) |
| 3498 | } |
| 3499 | |
| 3500 | /// B — new empty panel one second after the panel under the playhead (or |
| 3501 | /// the playhead itself), and the playhead jumps onto it: mash B to rough |
| 3502 | /// out shot timings a second apart before the real timings are known. |
| 3503 | func addPanelOneSecondLater() { |
| 3504 | let pc = playback |
| 3505 | var t = quantize(pc.playhead + 1) |
| 3506 | if let panel = project.clipAt(track: .storyboard, time: pc.playhead, |
| 3507 | kind: .storyboard) { |
| 3508 | t = quantize(panel.start + 1) |
| 3509 | } |
| 3510 | // addStoryboardPanel resolves any collision and seeks the playhead onto |
| 3511 | // the panel it actually created. |
| 3512 | addStoryboardPanel(at: t) |
| 3513 | } |
| 3514 | |
| 3515 | // MARK: - Markers |
| 3516 | |
| 3517 | /// Drop a marker at the playhead, or remove the one already sitting there |
| 3518 | /// (so the same key toggles). One undo step. |
| 3519 | func toggleMarkerAtPlayhead() { |
| 3520 | let t = quantize(playback.playhead) |
| 3521 | store.mutate { model in |
| 3522 | let half = 0.5 / max(1, model.fps) |
| 3523 | if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) { |
| 3524 | model.markers.remove(at: idx) |
| 3525 | } else { |
| 3526 | model.markers.append(Marker(time: t)) |
| 3527 | } |
| 3528 | } |
| 3529 | } |
| 3530 | |
| 3531 | /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start. |
| 3532 | func goToNextStoryboardPanel() { |
| 3533 | let t = playback.playhead |
| 3534 | guard let next = project.clips |
| 3535 | .filter({ $0.kind == .storyboard && $0.start > t + frameDur / 2 }) |
| 3536 | .map(\.start).min() |
| 3537 | else { return } |
| 3538 | playback.seek(to: next) |
| 3539 | } |
| 3540 | |
| 3541 | func goToPrevStoryboardPanel() { |
| 3542 | let t = playback.playhead |
| 3543 | guard let prev = project.clips |
| 3544 | .filter({ $0.kind == .storyboard && $0.start < t - frameDur / 2 }) |
| 3545 | .map(\.start).max() |
| 3546 | else { return } |
| 3547 | playback.seek(to: prev) |
| 3548 | } |
| 3549 | |
| 3550 | func goToNextMarker() { |
| 3551 | guard let m = project.nextMarker(after: playback.playhead) else { return } |
| 3552 | playback.seek(to: m.time) |
| 3553 | } |
| 3554 | |
| 3555 | func goToPrevMarker() { |
| 3556 | guard let m = project.prevMarker(before: playback.playhead) else { return } |
| 3557 | playback.seek(to: m.time) |
| 3558 | } |
| 3559 | |
| 3560 | func clearAllMarkers() { |
| 3561 | guard !project.markers.isEmpty else { return } |
| 3562 | store.mutate { $0.markers.removeAll() } |
| 3563 | } |
| 3564 | |
| 3565 | private func deleteMarker(_ id: UUID) { |
| 3566 | store.mutate { $0.markers.removeAll { $0.id == id } } |
| 3567 | } |
| 3568 | |
| 3569 | /// Set (or clear) a marker's label. Driven by the inline name field in the |
| 3570 | /// marker's right-click menu. |
| 3571 | private func setMarkerLabel(_ id: UUID, _ label: String) { |
| 3572 | store.mutate { model in |
| 3573 | if let i = model.markers.firstIndex(where: { $0.id == id }) { |
| 3574 | model.markers[i].label = label |
| 3575 | } |
| 3576 | } |
| 3577 | } |
| 3578 | |
| 3579 | // MARK: - Context menus (right-click everywhere) |
| 3580 | |
| 3581 | private var ctxTrack: TrackRef? |
| 3582 | private var ctxCompPath: String? |
| 3583 | private var ctxMarkerId: UUID? |
| 3584 | // The inline name field of the open marker menu, committed on menuDidClose. |
| 3585 | private weak var markerNameField: MarkerNameMenuField? |
| 3586 | private var markerNameOriginal = "" |
| 3587 | |
| 3588 | override func menu(for event: NSEvent) -> NSMenu? { |
| 3589 | let p = convert(event.locationInWindow, from: nil) |
| 3590 | lastMousePoint = p |
| 3591 | let menu = NSMenu() |
| 3592 | func add(_ title: String, _ action: Selector, key: String = "", |
| 3593 | mods: NSEvent.ModifierFlags = []) { |
| 3594 | let mi = NSMenuItem(title: title, action: action, keyEquivalent: key) |
| 3595 | mi.target = self |
| 3596 | mi.keyEquivalentModifierMask = mods |
| 3597 | menu.addItem(mi) |
| 3598 | } |
| 3599 | |
| 3600 | // Ruler: markers (rename/delete an existing flag, or add one here). |
| 3601 | if p.y < rulerH { |
| 3602 | if let m = markerAt(point: p) { |
| 3603 | ctxMarkerId = m.id |
| 3604 | // Name lives inline in the menu (like Frame Rate), not a popup. |
| 3605 | // Committed on menuDidClose so Return / click-away all persist. |
| 3606 | let nameField = MarkerNameMenuField(name: m.label) |
| 3607 | nameField.onReturn = { [weak menu] in menu?.cancelTracking() } |
| 3608 | markerNameField = nameField |
| 3609 | markerNameOriginal = m.label |
| 3610 | menu.delegate = self |
| 3611 | let nameItem = NSMenuItem() |
| 3612 | nameItem.view = nameField |
| 3613 | menu.addItem(nameItem) |
| 3614 | menu.addItem(.separator()) |
| 3615 | add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}") |
| 3616 | } else { |
| 3617 | add("Add Marker Here", #selector(ctxAddMarkerHere)) |
| 3618 | } |
| 3619 | if !project.markers.isEmpty { |
| 3620 | menu.addItem(.separator()) |
| 3621 | add("Clear All Markers", #selector(ctxClearMarkers)) |
| 3622 | } |
| 3623 | return menu |
| 3624 | } |
| 3625 | |
| 3626 | // Fusion comps |
| 3627 | if let comp = compAt(point: p) { |
| 3628 | comps.selectedCompPath = comp.path |
| 3629 | ctxCompPath = comp.path |
| 3630 | needsDisplay = true |
| 3631 | let preferred = project.preferredTakes.contains(comp.name) |
| 3632 | add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", |
| 3633 | #selector(ctxTogglePreferred), key: "t") |
| 3634 | add("Open in Fusion", #selector(ctxOpenComp)) |
| 3635 | menu.addItem(.separator()) |
| 3636 | add("Rescan Comps", #selector(ctxRescanComps)) |
| 3637 | return menu |
| 3638 | } |
| 3639 | if fusionBandH > 0, p.y > fusionTop, p.y < lanesTop { |
| 3640 | add(session.fusionHidden ? "Show Fusion Preview" : "Hide Fusion Preview", |
| 3641 | #selector(ctxToggleFusionHidden)) |
| 3642 | add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", |
| 3643 | #selector(ctxToggleFusionFocus)) |
| 3644 | add("Rescan Comps", #selector(ctxRescanComps)) |
| 3645 | return menu |
| 3646 | } |
| 3647 | |
| 3648 | // Track header |
| 3649 | if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) { |
| 3650 | ctxTrack = ref |
| 3651 | add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview", |
| 3652 | #selector(ctxToggleHidden)) |
| 3653 | add(session.focusedTracks.contains(ref) ? "Unfocus" : "Focus", |
| 3654 | #selector(ctxToggleFocus)) |
| 3655 | add("Show All Tracks", #selector(ctxResetVisibility)) |
| 3656 | menu.addItem(.separator()) |
| 3657 | // Only real video lanes can be deleted (the storyboard lane clears |
| 3658 | // itself when its panels are gone). |
| 3659 | if ref.videoIndex != nil { add("Delete Track", #selector(ctxDeleteTrack)) } |
| 3660 | add("Delete Empty Tracks", #selector(ctxDeleteEmptyTracks)) |
| 3661 | return menu |
| 3662 | } |
| 3663 | |
| 3664 | // Clips |
| 3665 | if let (clip, _) = clipAt(point: p) { |
| 3666 | if !store.selection.contains(clip.id) { store.selection = [clip.id] } |
| 3667 | add("Split at Playhead", #selector(ctxSplit), key: "s") |
| 3668 | if clip.kind == .storyboard { |
| 3669 | add("Split Storyboard at Playhead", #selector(ctxSplitStoryboard), key: "b") |
| 3670 | add("Split Storyboard, New Shot", #selector(ctxSplitStoryboardNewShot), |
| 3671 | key: "B", mods: .shift) |
| 3672 | add("Is New Shot", #selector(ctxToggleNewShot), key: "n") |
| 3673 | menu.items.last?.state = clip.newShot ? .on : .off |
| 3674 | add("New Panel 1 s Later", #selector(ctxPanelLater)) |
| 3675 | add("Open in Storyboard Window", #selector(ctxOpenBoard)) |
| 3676 | } |
| 3677 | add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m") |
| 3678 | if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) } |
| 3679 | menu.addItem(.separator()) |
| 3680 | if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") } |
| 3681 | if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) } |
| 3682 | add("Copy", #selector(ctxCopy)) |
| 3683 | menu.addItem(.separator()) |
| 3684 | add("Delete", #selector(ctxDelete), key: "\u{8}") |
| 3685 | add("Ripple Delete", #selector(ctxRippleDelete), key: "\\") |
| 3686 | return menu |
| 3687 | } |
| 3688 | |
| 3689 | // Empty lane space |
| 3690 | if rowAt(y: p.y) != nil { |
| 3691 | if blankGap(at: quantize(secondsFor(p.x))) != nil { |
| 3692 | add("Delete the Space (Close Gap)", #selector(ctxCloseGap), |
| 3693 | key: "\u{8}") |
| 3694 | menu.addItem(.separator()) |
| 3695 | } |
| 3696 | add("Add Storyboard Panel Here", #selector(ctxAddPanelHere)) |
| 3697 | if !project.overlaps().isEmpty { |
| 3698 | add("Move Overlaps to Separate Tracks", #selector(ctxMoveOverlaps), |
| 3699 | key: "o", mods: .option) |
| 3700 | } |
| 3701 | add("Paste Panel", #selector(ctxPaste)) |
| 3702 | return menu |
| 3703 | } |
| 3704 | return nil |
| 3705 | } |
| 3706 | |
| 3707 | @objc private func ctxTogglePreferred() { comps.togglePreferredTake() } |
| 3708 | @objc private func ctxOpenComp() { |
| 3709 | if let comp = comps.comp(at: ctxCompPath) { |
| 3710 | comps.openInFusion(comp) |
| 3711 | } |
| 3712 | } |
| 3713 | @objc private func ctxRescanComps() { comps.rescan() } |
| 3714 | @objc private func ctxDeleteMarker() { if let id = ctxMarkerId { deleteMarker(id) } } |
| 3715 | @objc private func ctxClearMarkers() { clearAllMarkers() } |
| 3716 | @objc private func ctxAddMarkerHere() { |
| 3717 | let t = max(0, quantize(secondsFor(lastMousePoint.x))) |
| 3718 | store.mutate { model in |
| 3719 | let half = 0.5 / max(1, model.fps) |
| 3720 | if !model.markers.contains(where: { abs($0.time - t) < half }) { |
| 3721 | model.markers.append(Marker(time: t)) |
| 3722 | } |
| 3723 | } |
| 3724 | } |
| 3725 | @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true } |
| 3726 | @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true } |
| 3727 | @objc private func ctxToggleHidden() { |
| 3728 | if let ref = ctxTrack { session.toggleHidden(ref); needsDisplay = true } |
| 3729 | } |
| 3730 | @objc private func ctxToggleFocus() { |
| 3731 | if let ref = ctxTrack { session.toggleFocus(ref); needsDisplay = true } |
| 3732 | } |
| 3733 | @objc private func ctxResetVisibility() { resetTrackVisibility() } |
| 3734 | @objc private func ctxDeleteTrack() { |
| 3735 | guard let vi = ctxTrack?.videoIndex else { return } |
| 3736 | store.mutate { model in |
| 3737 | guard model.tracks.count > 1 else { return } |
| 3738 | model.clips.removeAll { $0.track == .video(vi) } |
| 3739 | model.removeTrack(at: vi) // renumbers the lanes above it |
| 3740 | } |
| 3741 | } |
| 3742 | @objc private func ctxDeleteEmptyTracks() { deleteEmptyTracks() } |
| 3743 | @objc private func ctxSplit() { split() } |
| 3744 | @objc private func ctxSplitStoryboard() { splitStoryboardAtPlayhead() } |
| 3745 | @objc private func ctxSplitStoryboardNewShot() { splitStoryboardAtPlayhead(newShot: true) } |
| 3746 | @objc private func ctxOpenBoard() { |
| 3747 | if let id = store.selection.first, |
| 3748 | project.clip(id)?.kind == .storyboard { |
| 3749 | StoryboardEditor.shared.open(clipId: id, ctx: ctx) |
| 3750 | } |
| 3751 | } |
| 3752 | @objc private func ctxMute() { toggleMute() } |
| 3753 | @objc private func ctxResetSpeed() { |
| 3754 | let sel = store.selection |
| 3755 | store.mutate { model in |
| 3756 | for i in model.clips.indices where sel.contains(model.clips[i].id) { |
| 3757 | model.clips[i].speed = 1 |
| 3758 | } |
| 3759 | } |
| 3760 | } |
| 3761 | @objc private func ctxLink() { linkSelection() } |
| 3762 | @objc private func ctxUnlink() { unlinkSelection() } |
| 3763 | @objc private func ctxCopy() { copy(nil) } |
| 3764 | @objc private func ctxToggleNewShot() { toggleNewShot() } |
| 3765 | @objc private func ctxPanelLater() { addPanelOneSecondLater() } |
| 3766 | @objc private func ctxDelete() { deleteSelection() } |
| 3767 | @objc private func ctxRippleDelete() { rippleDelete() } |
| 3768 | @objc private func ctxMoveOverlaps() { moveOverlapsToSeparateTracks() } |
| 3769 | @objc private func ctxPaste() { paste(nil) } |
| 3770 | @objc private func ctxCloseGap() { closeBlankSpace(at: quantize(secondsFor(lastMousePoint.x))) } |
| 3771 | @objc private func ctxAddPanelHere() { |
| 3772 | playback.seek(to: max(0, quantize(secondsFor(lastMousePoint.x)))) |
| 3773 | addStoryboardPanel() |
| 3774 | } |
| 3775 | |
| 3776 | // MARK: - Zoom & pan |
| 3777 | |
| 3778 | /// Overscroll: ~600 px of empty room before 0 and a couple of minutes |
| 3779 | /// past the last clip (the playhead may live out there), but bounded. |
| 3780 | private func clampOrigin(_ o: Double) -> Double { |
| 3781 | let overscroll = 600.0 / pxPerSecond |
| 3782 | rebuildSceneIfNeeded() |
| 3783 | return min(max(o, -overscroll), cachedTimelineDuration + 120) |
| 3784 | } |
| 3785 | |
| 3786 | override func scrollWheel(with event: NSEvent) { |
| 3787 | if event.modifierFlags.contains(.command) { |
| 3788 | zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x) |
| 3789 | } else { |
| 3790 | let dy = event.scrollingDeltaY |
| 3791 | // Vertical scrolling moves through the tracks when they overflow; |
| 3792 | // otherwise (and for the horizontal axis) it pans time. |
| 3793 | if maxScrollY > 0, abs(dy) > abs(event.scrollingDeltaX) { |
| 3794 | scrollY = min(max(0, scrollY - dy), maxScrollY) |
| 3795 | } else { |
| 3796 | let dx = event.scrollingDeltaX != 0 ? event.scrollingDeltaX : dy |
| 3797 | originSecond = clampOrigin(originSecond - Double(dx) / pxPerSecond) |
| 3798 | } |
| 3799 | } |
| 3800 | needsDisplay = true |
| 3801 | } |
| 3802 | |
| 3803 | override func magnify(with event: NSEvent) { |
| 3804 | zoom(by: 1 + event.magnification, anchorX: convert(event.locationInWindow, from: nil).x) |
| 3805 | } |
| 3806 | |
| 3807 | private func zoom(by factor: CGFloat, anchorX: CGFloat) { |
| 3808 | let anchorSec = secondsFor(anchorX) |
| 3809 | pxPerSecond = min(max(pxPerSecond * Double(factor), 0.05), 4000) |
| 3810 | originSecond = anchorSec - Double(anchorX - headerW) / pxPerSecond |
| 3811 | needsDisplay = true |
| 3812 | } |
| 3813 | |
| 3814 | func zoomToFit() { |
| 3815 | let dur = max(10, project.timelineDuration) |
| 3816 | pxPerSecond = Double(bounds.width - headerW) * 0.92 / dur |
| 3817 | originSecond = -0.04 * dur |
| 3818 | needsDisplay = true |
| 3819 | } |
| 3820 | |
| 3821 | // MARK: - File drop import |
| 3822 | |
| 3823 | /// Live preview of where a hovering file drop will land: the resolved |
| 3824 | /// streams, their shared anchor, the drop time, and the first row. |
| 3825 | private struct FileDropPreview { |
| 3826 | var streams: [DropStream] |
| 3827 | var minOffset: Double |
| 3828 | var dropSec: Double |
| 3829 | var baseRow: Int |
| 3830 | } |
| 3831 | private var fileDropPreview: FileDropPreview? |
| 3832 | private var dropDurations: [String: Double] = [:] // path → probed seconds |
| 3833 | private var dropProbing: Set<String> = [] |
| 3834 | |
| 3835 | private func droppableFiles(from sender: NSDraggingInfo) -> [URL] { |
| 3836 | guard let urls = sender.draggingPasteboard |
| 3837 | .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] } |
| 3838 | return urls.filter { |
| 3839 | UI.importableExtensions.contains($0.pathExtension.lowercased()) |
| 3840 | || $0.lastPathComponent == "sync.json" |
| 3841 | || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true |
| 3842 | } |
| 3843 | } |
| 3844 | |
| 3845 | /// Probe (once) each stream's duration so the preview rect has real width. |
| 3846 | private func ensureDropDurations(_ streams: [DropStream]) { |
| 3847 | for s in streams where dropDurations[s.url.path] == nil |
| 3848 | && !dropProbing.contains(s.url.path) { |
| 3849 | dropProbing.insert(s.url.path) |
| 3850 | MediaPipeline.shared.probeDuration(s.url) { [weak self] d in |
| 3851 | guard let self else { return } |
| 3852 | self.dropProbing.remove(s.url.path) |
| 3853 | if let d, d > 0 { self.dropDurations[s.url.path] = d; self.needsDisplay = true } |
| 3854 | } |
| 3855 | } |
| 3856 | } |
| 3857 | |
| 3858 | private func updateFileDropPreview(_ sender: NSDraggingInfo) { |
| 3859 | let streams = expandDropStreams(droppableFiles(from: sender)) |
| 3860 | guard !streams.isEmpty else { fileDropPreview = nil; needsDisplay = true; return } |
| 3861 | let p = convert(sender.draggingLocation, from: nil) |
| 3862 | let count = laneRows.count |
| 3863 | fileDropPreview = FileDropPreview( |
| 3864 | streams: streams, |
| 3865 | minOffset: streams.map(\.offset).min() ?? 0, |
| 3866 | dropSec: max(0, quantize(secondsFor(p.x))), |
| 3867 | baseRow: rowAt(y: p.y).map { min($0, count) } ?? count) |
| 3868 | ensureDropDurations(streams) |
| 3869 | needsDisplay = true |
| 3870 | } |
| 3871 | |
| 3872 | override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { |
| 3873 | updateFileDropPreview(sender) |
| 3874 | return .copy |
| 3875 | } |
| 3876 | |
| 3877 | override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { |
| 3878 | updateFileDropPreview(sender) |
| 3879 | return .copy |
| 3880 | } |
| 3881 | |
| 3882 | override func draggingExited(_ sender: NSDraggingInfo?) { |
| 3883 | fileDropPreview = nil |
| 3884 | needsDisplay = true |
| 3885 | } |
| 3886 | |
| 3887 | override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { |
| 3888 | fileDropPreview = nil |
| 3889 | needsDisplay = true |
| 3890 | let p = convert(sender.draggingLocation, from: nil) |
| 3891 | let files = droppableFiles(from: sender) |
| 3892 | guard !files.isEmpty else { return false } |
| 3893 | let dropSec = max(0, quantize(secondsFor(p.x))) |
| 3894 | let row = rowAt(y: p.y).flatMap { $0 < laneRows.count ? $0 : nil } |
| 3895 | importFiles(files, atSecond: dropSec, targetRow: row) |
| 3896 | return true |
| 3897 | } |
| 3898 | |
| 3899 | /// Draw each hovering stream where it will actually land — real start time, |
| 3900 | /// probed width, one row per stream — creating ghost lanes below the last |
| 3901 | /// track exactly the way dragging an existing clip down does. |
| 3902 | private func drawFileDropPreview() { |
| 3903 | guard let dp = fileDropPreview else { return } |
| 3904 | let count = laneRows.count |
| 3905 | for (i, s) in dp.streams.enumerated() { |
| 3906 | let row = dp.baseRow + i |
| 3907 | let lane = laneRect(row: row) |
| 3908 | guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue } |
| 3909 | // Ghost lane outline for rows that don't exist yet. |
| 3910 | if row >= count { |
| 3911 | let outline = NSBezierPath(roundedRect: lane.insetBy(dx: 2, dy: 2), |
| 3912 | xRadius: 4, yRadius: 4) |
| 3913 | outline.setLineDash([4, 4], count: 2, phase: 0) |
| 3914 | Theme.dragHint.withAlphaComponent(0.5).setStroke() |
| 3915 | outline.stroke() |
| 3916 | } |
| 3917 | let start = max(0, dp.dropSec + (s.offset - dp.minOffset)) |
| 3918 | let known = dropDurations[s.url.path] |
| 3919 | let x0 = xFor(start) |
| 3920 | let w = max(3, CGFloat(known ?? 2) * CGFloat(pxPerSecond)) |
| 3921 | let rect = NSRect(x: x0, y: lane.minY + 1, width: w, height: lane.height - 2) |
| 3922 | guard rect.maxX > headerW, rect.minX < bounds.width else { continue } |
| 3923 | let body = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3) |
| 3924 | Theme.dragHint.withAlphaComponent(known != nil ? 0.35 : 0.18).setFill() |
| 3925 | body.fill() |
| 3926 | body.lineWidth = 1.5 |
| 3927 | if known == nil { body.setLineDash([3, 3], count: 2, phase: 0) } // still probing |
| 3928 | Theme.dragHint.withAlphaComponent(0.9).setStroke() |
| 3929 | body.stroke() |
| 3930 | } |
| 3931 | } |
| 3932 | |
| 3933 | /// One stream that a drop resolves to: a media file and its relative |
| 3934 | /// timeline offset (from a sync.json manifest, else 0). |
| 3935 | struct DropStream { let url: URL; let offset: Double } |
| 3936 | |
| 3937 | /// Expand a raw drop (files, folders, sync.json manifests) into the ordered, |
| 3938 | /// de-duplicated list of media streams it represents — the single source of |
| 3939 | /// truth shared by the landing preview and the actual import. |
| 3940 | func expandDropStreams(_ rawURLs: [URL]) -> [DropStream] { |
| 3941 | var out: [DropStream] = [] |
| 3942 | var manifestByDir: [String: SyncManifest?] = [:] |
| 3943 | func manifest(inDir dir: URL) -> SyncManifest? { |
| 3944 | if let cached = manifestByDir[dir.path] { return cached } |
| 3945 | let m = SyncManifest.load(dir.appendingPathComponent("sync.json")) |
| 3946 | manifestByDir[dir.path] = m |
| 3947 | return m |
| 3948 | } |
| 3949 | func addStreams(_ m: SyncManifest, dir: URL) { |
| 3950 | for (file, off) in m.offsetsByFile { |
| 3951 | let f = dir.appendingPathComponent(file) |
| 3952 | guard FileManager.default.fileExists(atPath: f.path) else { continue } |
| 3953 | out.append(DropStream(url: f, offset: off)) |
| 3954 | } |
| 3955 | } |
| 3956 | for u in rawURLs { |
| 3957 | let isDir = (try? u.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true |
| 3958 | if u.lastPathComponent == "sync.json", let m = SyncManifest.load(u) { |
| 3959 | addStreams(m, dir: u.deletingLastPathComponent()) |
| 3960 | } else if isDir, let m = SyncManifest.load(u.appendingPathComponent("sync.json")) { |
| 3961 | addStreams(m, dir: u) |
| 3962 | } else if isDir { |
| 3963 | continue // a plain folder with no manifest — don't dump its contents |
| 3964 | } else { |
| 3965 | // A media file dropped alongside a sync.json inherits its offset. |
| 3966 | let off = manifest(inDir: u.deletingLastPathComponent())? |
| 3967 | .offsetsByFile[u.lastPathComponent] ?? 0 |
| 3968 | out.append(DropStream(url: u, offset: off)) |
| 3969 | } |
| 3970 | } |
| 3971 | var seen = Set<String>() |
| 3972 | return out.filter { seen.insert($0.url.path).inserted } |
| 3973 | } |
| 3974 | |
| 3975 | /// Probes files off-main, then lands them in one undoable mutation. |
| 3976 | /// Files fill lanes downward from `targetRow`, reusing existing tracks |
| 3977 | /// before adding new ones (a dropped multicam set fills the lanes below). |
| 3978 | /// A recorder `sync.json` (dropped directly, as a folder, or sitting next |
| 3979 | /// to the media) offsets each stream so the session lands in sync. |
| 3980 | func importFiles(_ rawURLs: [URL], atSecond: Double, targetRow: Int?) { |
| 3981 | let streams = expandDropStreams(rawURLs) |
| 3982 | guard !streams.isEmpty else { return } |
| 3983 | let urls = streams.map(\.url) |
| 3984 | let offsetByPath = Dictionary(streams.map { ($0.url.path, $0.offset) }, |
| 3985 | uniquingKeysWith: { a, _ in a }) |
| 3986 | // Anchor the earliest stream at the drop point; the rest keep their |
| 3987 | // relative spacing. |
| 3988 | let minOffset = streams.map(\.offset).min() ?? 0 |
| 3989 | |
| 3990 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 3991 | userInfo: ["text": "Importing \(urls.count) file\(urls.count == 1 ? "" : "s")…"]) |
| 3992 | let group = DispatchGroup() |
| 3993 | var items: [MediaItem?] = Array(repeating: nil, count: urls.count) |
| 3994 | for (i, url) in urls.enumerated() { |
| 3995 | group.enter() |
| 3996 | MediaPipeline.shared.importFile(url) { item in |
| 3997 | items[i] = item |
| 3998 | group.leave() |
| 3999 | } |
| 4000 | } |
| 4001 | group.notify(queue: .main) { [weak self] in |
| 4002 | guard let self else { return } |
| 4003 | let ok = items.compactMap { $0 } |
| 4004 | guard !ok.isEmpty else { |
| 4005 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 4006 | userInfo: ["text": "Import failed: Could not probe media"]) |
| 4007 | return |
| 4008 | } |
| 4009 | self.store.mutate { model in |
| 4010 | if model.clips.isEmpty, let first = ok.first(where: { !$0.isAudio }), |
| 4011 | first.fps > 0 { |
| 4012 | model.fps = first.fps |
| 4013 | } |
| 4014 | // Files imported together are a multicam session: link them. |
| 4015 | let sessionLink: UUID? = ok.count > 1 ? UUID() : nil |
| 4016 | for (i, var item) in ok.enumerated() { |
| 4017 | // Re-import of an identical file reuses the existing entry. |
| 4018 | if let existing = model.media.first(where: { $0.cacheKey == item.cacheKey }) { |
| 4019 | item = existing |
| 4020 | } else { |
| 4021 | model.media.append(item) |
| 4022 | } |
| 4023 | // Files land on consecutive rows starting at the drop: |
| 4024 | // reuse the existing lanes below the target before making |
| 4025 | // new ones (new tracks append at the bottom, so the next |
| 4026 | // wanted row keeps matching as we go). |
| 4027 | let trackIndex: Int |
| 4028 | let rows = model.laneRefs |
| 4029 | let wantRow = targetRow.map { $0 + i } |
| 4030 | if let row = wantRow, rows.indices.contains(row), |
| 4031 | let vi = rows[row].videoIndex { |
| 4032 | trackIndex = vi |
| 4033 | } else { |
| 4034 | trackIndex = model.addTrack() |
| 4035 | } |
| 4036 | let off = (offsetByPath[item.path] ?? minOffset) - minOffset |
| 4037 | model.clips.append(Clip( |
| 4038 | mediaId: item.id, track: .video(trackIndex), |
| 4039 | start: atSecond + off, srcIn: 0, duration: item.duration, |
| 4040 | kind: item.isAudio ? .audio : .video, |
| 4041 | linkId: sessionLink)) |
| 4042 | } |
| 4043 | } |
| 4044 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 4045 | userInfo: ["text": "Imported \(ok.count) file\(ok.count == 1 ? "" : "s")"]) |
| 4046 | } |
| 4047 | } |
| 4048 | |
| 4049 | func zoomIn() { zoom(by: 1.4, anchorX: bounds.midX) } |
| 4050 | func zoomOut() { zoom(by: 1 / 1.4, anchorX: bounds.midX) } |
| 4051 | |
| 4052 | // MARK: - Test hooks |
| 4053 | |
| 4054 | func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) } |
| 4055 | var testPxPerSecond: Double { pxPerSecond } |
| 4056 | func testHThumb() -> NSRect { hThumbRect() } |
| 4057 | func testVThumb() -> NSRect { vThumbRect() } |
| 4058 | func testSetOrigin(_ sec: Double) { originSecond = sec } |
| 4059 | func testSetPxPerSecond(_ p: Double) { pxPerSecond = p } |
| 4060 | /// Draw straight into the current graphics context (set by the harness), |
| 4061 | /// bypassing `cacheDisplay`'s per-call bitmap allocation so we time `draw`. |
| 4062 | func testRedraw() { draw(bounds) } |
| 4063 | var testTimelineDuration: Double { project.timelineDuration } |
| 4064 | var testVisibleClipCount: Int { rebuildSceneIfNeeded(); return visibleClipCount() } |
| 4065 | |
| 4066 | // MARK: - Colors |
| 4067 | |
| 4068 | private func trackColor(_ ref: TrackRef) -> NSColor { |
| 4069 | NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1) |
| 4070 | } |
| 4071 | private func trackColorForClip(_ clip: Clip) -> NSColor { |
| 4072 | trackColor(clip.track) |
| 4073 | } |
| 4074 | } |
| 4075 | |
| 4076 | extension TimelineView: NSMenuDelegate { |
| 4077 | /// Commit the marker's inline name when its right-click menu closes — by |
| 4078 | /// Return, click-away, or picking another item. One undo step, and none at |
| 4079 | /// all if the name is unchanged. |
| 4080 | func menuDidClose(_ menu: NSMenu) { |
| 4081 | guard let field = markerNameField, let id = ctxMarkerId else { return } |
| 4082 | markerNameField = nil |
| 4083 | let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 4084 | if value != markerNameOriginal { setMarkerLabel(id, value) } |
| 4085 | } |
| 4086 | } |
| 4087 | |
| 4088 | extension NSImage { |
| 4089 | func tinted(_ color: NSColor) -> NSImage { |
| 4090 | let img = NSImage(size: size, flipped: false) { rect in |
| 4091 | color.set() |
| 4092 | rect.fill() |
| 4093 | self.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1) |
| 4094 | return true |
| 4095 | } |
| 4096 | return img |
| 4097 | } |
| 4098 | } |
| 4099 | |
| 4100 | /// An inline, editable marker-name row hosted inside the marker's right-click |
| 4101 | /// menu — type a name and press Return to commit, the way Frame Rate lives in |
| 4102 | /// the menu instead of a separate dialog. Replaces the old rename popup. |
| 4103 | /// The menu swallows Return before the field's action fires, so we mirror |
| 4104 | /// every keystroke into `text` and let `TimelineView.menuDidClose` commit it. |
| 4105 | final class MarkerNameMenuField: NSView, NSTextFieldDelegate { |
| 4106 | private let field = NSTextField() |
| 4107 | /// Live copy of what's typed, kept current so the commit doesn't depend on |
| 4108 | /// the field editor still being attached as the menu tears down. |
| 4109 | private(set) var text: String |
| 4110 | /// Set by the menu builder so Return dismisses the menu (which commits). |
| 4111 | var onReturn: (() -> Void)? |
| 4112 | |
| 4113 | init(name: String) { |
| 4114 | self.text = name |
| 4115 | super.init(frame: NSRect(x: 0, y: 0, width: 208, height: 26)) |
| 4116 | let caption = NSTextField(labelWithString: "Name") |
| 4117 | caption.font = .menuFont(ofSize: 0) |
| 4118 | caption.textColor = .secondaryLabelColor |
| 4119 | caption.frame = NSRect(x: 14, y: 5, width: 38, height: 16) |
| 4120 | addSubview(caption) |
| 4121 | field.frame = NSRect(x: 52, y: 3, width: 142, height: 20) |
| 4122 | field.stringValue = name |
| 4123 | field.placeholderString = "Marker name" |
| 4124 | field.font = .menuFont(ofSize: 0) |
| 4125 | field.focusRingType = .none |
| 4126 | field.delegate = self |
| 4127 | field.target = self |
| 4128 | field.action = #selector(returnPressed) |
| 4129 | addSubview(field) |
| 4130 | } |
| 4131 | required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| 4132 | |
| 4133 | override func viewDidMoveToWindow() { |
| 4134 | super.viewDidMoveToWindow() |
| 4135 | guard window != nil else { return } |
| 4136 | // The menu hosts us in its own window; grab focus so keystrokes land in |
| 4137 | // the field rather than the menu's key-equivalent matcher. |
| 4138 | DispatchQueue.main.async { [weak self] in |
| 4139 | guard let self else { return } |
| 4140 | self.window?.makeFirstResponder(self.field) |
| 4141 | } |
| 4142 | } |
| 4143 | |
| 4144 | func controlTextDidChange(_ obj: Notification) { text = field.stringValue } |
| 4145 | @objc private func returnPressed() { onReturn?() } |
| 4146 | } |