| 1 | import AppKit |
| 2 | |
| 3 | /// Headless draw-performance harness: `sequencer --perftest <file.sq>`. |
| 4 | /// Loads a real project into an offscreen `TimelineView` and times `draw(_:)` |
| 5 | /// while simulating a horizontal pan, at a few zoom levels, in both full-detail |
| 6 | /// and simplified (mid-scroll) modes. Reports milliseconds per frame so timeline |
| 7 | /// rendering changes can be judged against real numbers instead of feel. |
| 8 | @MainActor |
| 9 | func runPerfTest(path: String) { |
| 10 | let url = URL(fileURLWithPath: path) |
| 11 | var isDir: ObjCBool = false |
| 12 | FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) |
| 13 | let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url |
| 14 | guard let data = try? Data(contentsOf: jsonURL), |
| 15 | let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else { |
| 16 | print("perftest: could not read \(jsonURL.path)") |
| 17 | exit(1) |
| 18 | } |
| 19 | |
| 20 | let ctx = DocumentContext.headless |
| 21 | ctx.session.apply(doc.view) |
| 22 | ctx.store.replaceForTest(doc.project) |
| 23 | |
| 24 | let W: CGFloat = 2000, H: CGFloat = 600 |
| 25 | let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: W, height: H)) |
| 26 | let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H), |
| 27 | styleMask: [.borderless], backing: .buffered, defer: false) |
| 28 | window.contentView = timeline |
| 29 | |
| 30 | // Persistent bitmap-backed context: set current ONCE so we time draw(), not |
| 31 | // per-frame bitmap allocation. |
| 32 | let rep = timeline.bitmapImageRepForCachingDisplay(in: timeline.bounds)! |
| 33 | let gctx = NSGraphicsContext(bitmapImageRep: rep)! |
| 34 | |
| 35 | let dur = timeline.testTimelineDuration |
| 36 | print(String(format: "project: %d clips, %.0f s span, view %.0f×%.0f", |
| 37 | doc.project.clips.count, dur, W, H)) |
| 38 | |
| 39 | let panSpan = max(1, dur * 0.5) |
| 40 | |
| 41 | // filmstripImage loads thumbnails ASYNC on the main queue, so a tight draw |
| 42 | // loop that never spins the runloop measures the thumbnail-LESS path. Warm |
| 43 | // the cache: draw across the pan range at the current zoom and pump the |
| 44 | // runloop until the loads land, so we then measure real thumbnail blits. |
| 45 | func warmThumbnails() { |
| 46 | ctx.session.showFilmstrips = true |
| 47 | NSGraphicsContext.saveGraphicsState() |
| 48 | NSGraphicsContext.current = gctx |
| 49 | for _ in 0..<5 { |
| 50 | for i in 0..<40 { |
| 51 | timeline.testSetOrigin(panSpan * Double(i) / 40) |
| 52 | timeline.testRedraw() |
| 53 | } |
| 54 | RunLoop.current.run(until: Date().addingTimeInterval(0.2)) |
| 55 | } |
| 56 | NSGraphicsContext.restoreGraphicsState() |
| 57 | } |
| 58 | |
| 59 | // Pan the timeline over `frames` steps, timing each direct draw. Returns |
| 60 | // (median, p90) ms/frame — the median resists the I/O-contention spikes |
| 61 | // that async thumbnail loads inject into a mean. |
| 62 | // |
| 63 | // Two pan patterns: `panPxPerFrame` nil teleports across half the project |
| 64 | // (cache-hostile — approximates fresh zooms and scrubber jumps); a value |
| 65 | // pans that many px per frame like a real scroll gesture. |
| 66 | func measure(frames: Int = 120, panPxPerFrame: Double? = nil) |
| 67 | -> (median: Double, p90: Double) { |
| 68 | NSGraphicsContext.saveGraphicsState() |
| 69 | NSGraphicsContext.current = gctx |
| 70 | defer { NSGraphicsContext.restoreGraphicsState() } |
| 71 | for _ in 0..<3 { timeline.testRedraw() } // warm up |
| 72 | var times: [Double] = [] |
| 73 | times.reserveCapacity(frames) |
| 74 | for i in 0..<frames { |
| 75 | if let px = panPxPerFrame { |
| 76 | timeline.testSetOrigin(panSpan * 0.25 |
| 77 | + Double(i) * px / timeline.testPxPerSecond) |
| 78 | } else { |
| 79 | timeline.testSetOrigin(panSpan * Double(i) / Double(frames)) |
| 80 | } |
| 81 | let t0 = DispatchTime.now().uptimeNanoseconds |
| 82 | timeline.testRedraw() |
| 83 | times.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6) |
| 84 | } |
| 85 | times.sort() |
| 86 | return (times[frames / 2], times[Int(Double(frames) * 0.9)]) |
| 87 | } |
| 88 | |
| 89 | // pxPerSecond values: fit-all (everything on screen at once) up through |
| 90 | // progressively tighter section zooms; `empty` isolates fixed per-frame |
| 91 | // overhead (chrome + machinery) with ~no clips on screen. |
| 92 | let fitPps = max(0.05, Double(W - 26) / max(1, dur)) |
| 93 | let zooms: [(String, Double)] = [ |
| 94 | ("fit-all", fitPps), |
| 95 | ("section", 6), |
| 96 | ("tight", 30), |
| 97 | ("empty", 4000), |
| 98 | ] |
| 99 | ctx.session.showFilmstrips = true |
| 100 | print("mode zoom px/s visible med ms p90 ms (fps) thumbs/frame") |
| 101 | for (name, pps) in zooms { |
| 102 | timeline.testSetPxPerSecond(pps) |
| 103 | timeline.testSetOrigin(0) |
| 104 | let visible = timeline.testVisibleClipCount |
| 105 | warmThumbnails() |
| 106 | // Count thumbnails actually blitted over one profiled pan pass. |
| 107 | DrawProf.on = true; DrawProf.thumbHits = 0 |
| 108 | NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = gctx |
| 109 | for i in 0..<60 { timeline.testSetOrigin(panSpan * Double(i) / 60); timeline.testRedraw() } |
| 110 | NSGraphicsContext.restoreGraphicsState() |
| 111 | let thumbsPerFrame = DrawProf.thumbHits / 60 |
| 112 | DrawProf.on = false |
| 113 | // A/B inside one process: the direct (Stage-1) path vs the tile cache, |
| 114 | // measured seconds apart so thermal drift can't skew the comparison. |
| 115 | // "scroll" pans 8 px/frame like a real gesture; "jump" teleports |
| 116 | // half-project strides (cache-hostile worst case). |
| 117 | let px8 = 8.0 |
| 118 | for (mode, disabled, pan) in [ |
| 119 | ("direct-scroll", true, px8 as Double?), |
| 120 | ("tiled·-scroll", false, px8), |
| 121 | ("direct-jump··", true, nil), |
| 122 | ("tiled·-jump··", false, nil), |
| 123 | ] { |
| 124 | TimelineView.tilesDisabled = disabled |
| 125 | _ = measure(frames: 30, panPxPerFrame: pan) // warm/fill cache |
| 126 | let ms = measure(panPxPerFrame: pan) |
| 127 | print(String(format: "%@ %-9@ %7.2f %7d %8.2f %8.2f %5.0f %6d", |
| 128 | mode, name as NSString, pps, visible, ms.median, ms.p90, |
| 129 | 1000 / max(0.001, ms.median), thumbsPerFrame)) |
| 130 | } |
| 131 | // Zoom simulation: a continuous pinch around this zoom level (±1.5×, |
| 132 | // ~1.5%/frame like a real magnify stream). Every frame changes |
| 133 | // pxPerSecond, so the tile cache can never hit — this is the pure |
| 134 | // cold-render rate that 60 Hz zooming needs. |
| 135 | TimelineView.tilesDisabled = false |
| 136 | do { |
| 137 | let anchor = panSpan * 0.25 |
| 138 | NSGraphicsContext.saveGraphicsState() |
| 139 | NSGraphicsContext.current = gctx |
| 140 | for _ in 0..<3 { timeline.testRedraw() } |
| 141 | var times: [Double] = [] |
| 142 | DrawProf.on = true; DrawProf.reset() |
| 143 | for i in 0..<120 { |
| 144 | let phase = Double(i) / 120 * 4 * .pi |
| 145 | let f = exp(sin(phase) * 0.4) // pps swings ×0.67…×1.5 |
| 146 | timeline.testSetPxPerSecond(pps * f) |
| 147 | timeline.testSetOrigin(anchor) |
| 148 | let t0 = DispatchTime.now().uptimeNanoseconds |
| 149 | timeline.testRedraw() |
| 150 | times.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6) |
| 151 | } |
| 152 | DrawProf.on = false |
| 153 | NSGraphicsContext.restoreGraphicsState() |
| 154 | times.sort() |
| 155 | print(String(format: "zoom·-pinch·· %-9@ %7.2f %7d %8.2f %8.2f %5.0f", |
| 156 | name as NSString, pps, visible, times[60], times[108], |
| 157 | 1000 / max(0.001, times[60]))) |
| 158 | DrawProf.report(frames: 120) // per-phase cost of a cold zoom frame |
| 159 | timeline.testSetPxPerSecond(pps) |
| 160 | } |
| 161 | // Per-section breakdown of a tiled pan pass at this zoom. |
| 162 | DrawProf.on = true; DrawProf.reset() |
| 163 | DrawProf.tileRenders = 0; DrawProf.tileBlits = 0 |
| 164 | _ = measure(frames: 60) |
| 165 | DrawProf.on = false |
| 166 | DrawProf.report(frames: 60) |
| 167 | print(String(format: " tiles: %.1f blits, %.2f renders /frame", |
| 168 | Double(DrawProf.tileBlits) / 60, Double(DrawProf.tileRenders) / 60)) |
| 169 | TimelineView.tilesDisabled = false |
| 170 | } |
| 171 | exit(0) |
| 172 | } |