1import 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
9func 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}