Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ jobs:
build:
needs: test
runs-on: xcode-27
timeout-minutes: 10
# Cold Swift release compilation + the optional Metal search helper exceeded the old
# 10-minute cap without a compiler error (PR #19). Keep a bounded job with enough headroom.
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Validate release scripts
Expand Down
47 changes: 35 additions & 12 deletions Sources/Cutmark/CaptureEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,26 +161,49 @@ enum CaptureEventLog {
}
}

static func markers(in session: URL, screenFile: URL, through duration: Double) -> [CaptureEvent] {
static func markers(in session: URL, screenFile: URL, through duration: Double,
shouldCancel: () -> Bool = { false }) -> [CaptureEvent] {
guard let take = takeNumber(forScreenFile: screenFile) else { return [] }
let file = url(in: session, take: take)
guard let h = header(file), valid(h, take: take),
let handle = try? FileHandle(forReadingFrom: file) else { return [] }
defer { try? handle.close() }
let decoder = JSONDecoder()
var data = Data()
// A damaged JSONL can have an enormous unterminated line. Decode complete, bounded
// lines only; never retain the whole file or construct unlimited marker UI controls.
let maxLineBytes = 16 * 1024
let maxMarkers = 600
var line = Data()
var skippingOversizedLine = false
var out: [CaptureEvent] = []
while true {
guard let chunk = try? handle.read(upToCount: 64 * 1024), !chunk.isEmpty else { break }
data.append(chunk)
while let newline = data.firstIndex(of: 0x0A) {
let line = data[..<newline]
data.removeSubrange(...newline)
// Pointer events dominate the file. Avoid Codable work unless the flat line says marker.
guard line.range(of: Data("\"marker\"".utf8)) != nil,
let event = try? decoder.decode(CaptureEvent.self, from: Data(line)),
valid(event), event.type == .marker, event.t <= duration + 0.001 else { continue }
out.append(event)
guard !shouldCancel(), let chunk = try? handle.read(upToCount: 64 * 1024),
!chunk.isEmpty else { break }
var position = chunk.startIndex
while position < chunk.endIndex {
if shouldCancel() { return [] }
let newline = chunk[position...].firstIndex(of: 0x0A)
let end = newline ?? chunk.endIndex
let piece = chunk[position..<end]
if !skippingOversizedLine {
if line.count + piece.count <= maxLineBytes {
line.append(contentsOf: piece)
} else {
line.removeAll(keepingCapacity: true)
skippingOversizedLine = true
}
}
guard let newline else { break }
// Pointer events dominate the file. Avoid Codable work unless the line says marker.
if !skippingOversizedLine, line.range(of: Data("\"marker\"".utf8)) != nil,
let event = try? decoder.decode(CaptureEvent.self, from: line),
valid(event), event.type == .marker, event.t <= duration + 0.001 {
out.append(event)
if out.count == maxMarkers { return out }
}
line.removeAll(keepingCapacity: true)
skippingOversizedLine = false
position = chunk.index(after: newline)
}
}
return out
Expand Down
39 changes: 14 additions & 25 deletions Sources/Cutmark/TimelineEditorWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ private struct TimelineEditorWorkspace: View {
let doc: SessionDoc
@StateObject private var playback = TimelineEditorPlayback()
@StateObject private var media = TimelineMediaCache()
@StateObject private var markers = TimelineMarkerCache()
@State private var draft: EditTimelineDraft?
@State private var selected = 0
@State private var revision = 0
Expand Down Expand Up @@ -298,8 +299,12 @@ private struct TimelineEditorWorkspace: View {
guard let draft else { return }
await media.load(draft: draft, session: doc.url)
}
.task(id: revision) {
guard let draft else { return }
await markers.load(draft: draft, session: doc.url)
}
.onChange(of: duration) { _, _ in if zoomToFit { fitTimeline() } }
.onDisappear { playback.close(); media.cancel() }
.onDisappear { playback.close(); media.cancel(); markers.clear() }
}

private var header: some View {
Expand Down Expand Up @@ -557,30 +562,14 @@ private struct TimelineEditorWorkspace: View {
}

private var ruler: some View {
Canvas { context, size in
let step = TimelineCanvasLayout.rulerStep(pixelsPerSecond: pixelsPerSecond)
let count = Int(min(6000, (Double(size.width) / pixelsPerSecond / step).rounded(.down)))
for n in 0...count {
let seconds = Double(n) * step
let x = CGFloat(seconds * pixelsPerSecond)
let pastOutput = seconds > duration
var line = Path()
line.move(to: CGPoint(x: x, y: 18))
line.addLine(to: CGPoint(x: x, y: 26))
context.stroke(line, with: .color(Theme.muted.opacity(pastOutput ? 0.25 : 0.8)),
lineWidth: 0.7)
let label = context.resolve(Text(formatTime(seconds)).font(Theme.mono(9.5))
.foregroundStyle(Theme.muted.opacity(pastOutput ? 0.35 : 1)))
context.draw(label, at: CGPoint(x: x + (n == 0 ? 20 : 3), y: 7), anchor: .leading)
}
}
.frame(height: TimelineCanvasLayout.rulerHeight)
.background(Theme.canvas)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { move in
scrubTimeline(at: move.location.x)
})
.help("Click or drag to move the playhead. The blank tail is not exported.")
TimelineMarkerRuler(pins: markers.pins, pixelsPerSecond: pixelsPerSecond,
duration: duration, onSeek: { pin in
playback.stop()
selected = pin.index
// A marker is already on the renderer's source clock. Preserve its exact timestamp,
// even when draft.fps differs from the first source's output frame rate.
playback.seek(pin.outputTime, duration: duration)
}, onScrub: { scrubTimeline(at: $0) })
}

/// The red handle is deliberately wider than the 2px cursor, so it can be grabbed at
Expand Down
186 changes: 186 additions & 0 deletions Sources/Cutmark/TimelineMarkers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import AppKit
import AVFoundation
import Combine
import SwiftUI

/// A screen-source marker as one occurrence in the edited output. A duplicated range
/// intentionally creates two pins, while a trimmed-away marker creates none.
struct TimelineMarkerPin: Equatable, Identifiable {
let index: Int
let take: Int
let ordinal: Int
let sourceTime: Double
let outputTime: Double
let label: String

var id: String { "\(index):\(take):\(ordinal)" }
}

enum TimelineMarkers {
/// The editor and TimelinePlayback both use the renderer's frame-rounded segment clock.
/// Only actual screen frames receive pins; never infer a camera-to-screen offset.
static func project(draft: EditTimelineDraft, eventsByTake: [Int: [CaptureEvent]]) -> [TimelineMarkerPin] {
var pins: [TimelineMarkerPin] = []
var at = 0.0
for (index, segment) in draft.segments.enumerated() {
defer { at += segment.duration }
guard segment.kind == TakeKind.screen.rawValue, segment.duration.isFinite,
segment.duration > 0, let events = eventsByTake[segment.take] else { continue }
let sourceStart = segment.renderSourceStart ?? segment.start
let sourceEnd = min(segment.end, sourceStart + segment.duration)
guard sourceStart.isFinite, sourceEnd.isFinite, sourceEnd > sourceStart else { continue }
for (ordinal, event) in events.enumerated() where event.type == .marker && CaptureEventLog.valid(event) {
// Source intervals are half-open. A marker on a cut boundary belongs to the
// next occurrence, and no marker can appear in the non-exported tail.
guard event.t >= sourceStart, event.t < sourceEnd else { continue }
let output = at + event.t - sourceStart
guard output.isFinite, output >= at, output < at + segment.duration else { continue }
pins.append(.init(index: index, take: segment.take, ordinal: ordinal, sourceTime: event.t,
outputTime: output,
label: String((event.label.flatMap { $0.isEmpty ? nil : $0 }
?? "Marker \(ordinal + 1)").prefix(120))))
}
}
let sorted = pins.sorted {
if $0.outputTime != $1.outputTime { return $0.outputTime < $1.outputTime }
return $0.index < $1.index
}
// Marker-rich imports and duplicated clips must never create thousands of SwiftUI
// buttons. Keep a deterministic sample across the entire output, including both ends.
let maximumVisible = 320
guard sorted.count > maximumVisible else { return sorted }
return (0..<maximumVisible).map { i in
sorted[i * (sorted.count - 1) / (maximumVisible - 1)]
}
}
}

@MainActor
final class TimelineMarkerCache: ObservableObject {
@Published private(set) var pins: [TimelineMarkerPin] = []

private struct Key: Hashable {
let source: URL
let sourceSize: Int64
let sourceDate: Date?
let log: URL
let logSize: Int64
let logDate: Date?

init?(source: URL, log: URL) {
guard let media = try? FileManager.default.attributesOfItem(atPath: source.path),
let events = try? FileManager.default.attributesOfItem(atPath: log.path),
let mediaSize = media[.size] as? NSNumber,
let eventsSize = events[.size] as? NSNumber else { return nil }
self.source = source
sourceSize = mediaSize.int64Value
sourceDate = media[.modificationDate] as? Date
self.log = log
logSize = eventsSize.int64Value
logDate = events[.modificationDate] as? Date
}
}

private var retained: [Key: [CaptureEvent]] = [:]
private var generation = 0

func load(draft: EditTimelineDraft, session: URL) async {
generation += 1
let mine = generation
// One read per screen take even when the edited timeline uses it many times.
var byTake: [Int: URL] = [:]
for segment in draft.segments where segment.kind == TakeKind.screen.rawValue {
if byTake[segment.take] == nil {
byTake[segment.take] = TimelineMedia.sourceURL(for: segment, session: session)
}
}
var fetched: [Int: [CaptureEvent]] = [:]
var activeKeys: Set<Key> = []
let root = session.standardizedFileURL.resolvingSymlinksInPath()
let rootPrefix = root.path.hasSuffix("/") ? root.path : root.path + "/"
for (take, source) in byTake.sorted(by: { $0.key < $1.key }) {
guard !Task.isCancelled, generation == mine else { return }
let log = CaptureEventLog.url(in: session, take: take)
// Ignore a redirected events folder; timeline markers must come from this session.
guard log.standardizedFileURL.resolvingSymlinksInPath().path.hasPrefix(rootPrefix)
else { continue }
guard let key = Key(source: source, log: log) else { continue }
activeKeys.insert(key)
if let cached = retained[key] { fetched[take] = cached; continue }
let movie = AVURLAsset(url: source)
guard let duration = try? await movie.load(.duration).seconds,
duration.isFinite, duration > 0 else { continue }
let events = await Task.detached(priority: .utility) {
CaptureEventLog.markers(in: session, screenFile: source, through: duration,
shouldCancel: { Task.isCancelled })
}.value
guard !Task.isCancelled, generation == mine else { return }
retained[key] = events
fetched[take] = events
}
guard !Task.isCancelled, generation == mine else { return }
retained = retained.filter { activeKeys.contains($0.key) }
pins = TimelineMarkers.project(draft: draft, eventsByTake: fetched)
}

func clear() {
generation += 1
pins = []
retained = [:]
}
}

/// One ruler for timeline time labels and source-derived recording markers.
struct TimelineMarkerRuler: View {
let pins: [TimelineMarkerPin]
let pixelsPerSecond: Double
let duration: Double
let onSeek: (TimelineMarkerPin) -> Void
let onScrub: (CGFloat) -> Void

var body: some View {
ZStack(alignment: .topLeading) {
Canvas { context, size in
let step = TimelineCanvasLayout.rulerStep(pixelsPerSecond: pixelsPerSecond)
let count = Int(min(6000, (Double(size.width) / pixelsPerSecond / step).rounded(.down)))
for n in 0...count {
let seconds = Double(n) * step
let x = CGFloat(seconds * pixelsPerSecond)
let pastOutput = seconds > duration
var line = Path()
line.move(to: CGPoint(x: x, y: 18))
line.addLine(to: CGPoint(x: x, y: 26))
context.stroke(line, with: .color(Theme.muted.opacity(pastOutput ? 0.25 : 0.8)),
lineWidth: 0.7)
let label = context.resolve(Text(formatTime(seconds)).font(Theme.mono(9.5))
.foregroundStyle(Theme.muted.opacity(pastOutput ? 0.35 : 1)))
context.draw(label, at: CGPoint(x: x + (n == 0 ? 20 : 3), y: 7), anchor: .leading)
}
}
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { onScrub($0.location.x) })
.help("Click or drag to seek. The blank tail is not exported.")

ForEach(pins) { pin in
Button { onSeek(pin) } label: {
Image(systemName: "bookmark.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.accent)
.frame(width: 19, height: 17)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.offset(x: CGFloat(pin.outputTime * pixelsPerSecond) - 9.5, y: 15)
.accessibilityLabel("\(pin.label) · Take \(pin.take) · \(formatTime(pin.outputTime))")
.help("\(pin.label) · \(formatTime(pin.outputTime))")
}
}
.frame(height: TimelineCanvasLayout.rulerHeight)
.background(Theme.canvas)
}

private func formatTime(_ seconds: Double) -> String {
let value = max(0, seconds.isFinite ? seconds : 0)
return String(format: "%01d:%05.2f", Int(value / 60), value.truncatingRemainder(dividingBy: 60))
}
}
Loading
Loading