From c02cb9a0f7b4b2ba2d7858235ead6ed0972dd147 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Thu, 22 Oct 2026 21:17:57 -0700 Subject: [PATCH 1/2] Timeline: navigate recording markers on output clock --- Sources/Cutmark/CaptureEvents.swift | 47 +++- Sources/Cutmark/TimelineEditorWindow.swift | 39 ++-- Sources/Cutmark/TimelineMarkers.swift | 186 +++++++++++++++ Tests/CutmarkTests/TimelineMarkerTests.swift | 227 +++++++++++++++++++ 4 files changed, 462 insertions(+), 37 deletions(-) create mode 100644 Sources/Cutmark/TimelineMarkers.swift create mode 100644 Tests/CutmarkTests/TimelineMarkerTests.swift diff --git a/Sources/Cutmark/CaptureEvents.swift b/Sources/Cutmark/CaptureEvents.swift index d798d7f..fb2e7d7 100644 --- a/Sources/Cutmark/CaptureEvents.swift +++ b/Sources/Cutmark/CaptureEvents.swift @@ -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[.. 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 diff --git a/Sources/Cutmark/TimelineMarkers.swift b/Sources/Cutmark/TimelineMarkers.swift new file mode 100644 index 0000000..ebeb1b1 --- /dev/null +++ b/Sources/Cutmark/TimelineMarkers.swift @@ -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.. = [] + 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)) + } +} diff --git a/Tests/CutmarkTests/TimelineMarkerTests.swift b/Tests/CutmarkTests/TimelineMarkerTests.swift new file mode 100644 index 0000000..81ab501 --- /dev/null +++ b/Tests/CutmarkTests/TimelineMarkerTests.swift @@ -0,0 +1,227 @@ +import AppKit +import AVFoundation +import Foundation +import SwiftUI +import Testing +@testable import Cutmark + +struct TimelineMarkerTests { + private func screen(_ take: Int = 4, _ start: Double = 1, _ end: Double = 3, + fps: Double = 60) -> EditTimelineDraft.Segment { + .init(take: take, kind: "screen", start: start, end: end, sourceFPS: fps) + } + + @Test func trimReorderAndDuplicateProjectEachScreenOccurrenceOnOutputClock() { + let screen = screen() + let camera = EditTimelineDraft.Segment(take: 4, kind: "camera", start: 0, end: 1, sourceFPS: 30) + let draft = EditTimelineDraft(base: "edits/demo-v1.mp4", fps: 60, + source: [screen, camera], + segments: [camera, screen, screen]) + let events = [ + CaptureEvent(t: 0.99, type: .marker, label: "Trimmed"), + CaptureEvent(t: 1, type: .marker, label: "Beginning"), + CaptureEvent(t: 1.5, type: .marker, label: "Important"), + CaptureEvent(t: 3, type: .marker, label: "End boundary"), + CaptureEvent(t: 2, type: .down, label: "Click"), + ] + let pins = TimelineMarkers.project(draft: draft, eventsByTake: [4: events]) + #expect(pins.count == 4) + #expect(pins.map(\.label) == ["Beginning", "Important", "Beginning", "Important"]) + #expect(pins.map(\.index) == [1, 1, 2, 2]) + #expect(Set(pins.map(\.id)).count == pins.count) + #expect(abs(pins[0].outputTime - 1) < 0.00001) + #expect(abs(pins[1].outputTime - 1.5) < 0.00001) + #expect(abs(pins[2].outputTime - 3) < 0.00001) + #expect(abs(pins[3].outputTime - 3.5) < 0.00001) + // The center-screen marker selects the same occurrence as timeline scrubbing. + #expect(TimelineCanvasLayout.clipIndex(at: pins[1].outputTime, + durations: draft.segments.map(\.duration)) == pins[1].index) + } + + @Test func mixedFPSUsesFrameRoundedPreviewSpansRatherThanRawCutDurations() { + let mixed = screen(9, 0.007, 1.014, fps: 60) + let camera = EditTimelineDraft.Segment(take: 5, kind: "camera", + start: 0.011, end: 1.019, sourceFPS: 30) + let draft = EditTimelineDraft(base: "edits/demo-v2.mp4", fps: 30, + source: [camera, mixed], + segments: [camera, mixed]) + let events = [CaptureEvent(t: 0, type: .marker, label: "Before source trim"), + CaptureEvent(t: 0.5, type: .marker, label: "Center"), + CaptureEvent(t: 1.014, type: .marker, label: "Boundary")] + let pins = TimelineMarkers.project(draft: draft, eventsByTake: [9: events]) + #expect(pins.count == 2) + #expect(abs(pins[0].outputTime - 31.0 / 30.0) < 0.00001) + #expect(abs(pins[1].outputTime - (31.0 / 30.0 + 0.5)) < 0.00001) + #expect(draft.segments[1].renderSourceStart == 0) + #expect(pins.allSatisfy { $0.outputTime < draft.duration }) + } + + @Test func cameraOnlyAndMissingOrCorruptEventsNeverMakePins() throws { + let camera = EditTimelineDraft.Segment(take: 7, kind: "camera", start: 0, end: 2) + let onlyCamera = EditTimelineDraft(base: "edits/camera-v1.mp4", fps: 30, + source: [camera], segments: [camera]) + #expect(TimelineMarkers.project(draft: onlyCamera, eventsByTake: [ + 7: [.init(t: 1, type: .marker, label: "Screen-only")]]).isEmpty) + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-markers-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let renamed = root.appending(path: "take-07-demo-title-screen.mov") + #expect(CaptureEventLog.markers(in: root, screenFile: renamed, through: 2).isEmpty) + let file = CaptureEventLog.url(in: root, take: 7) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("not a header\n".utf8).write(to: file) + #expect(CaptureEventLog.markers(in: root, screenFile: renamed, through: 2).isEmpty) + try FileManager.default.removeItem(at: file) + let writer = try CaptureEventLog.Writer(url: file, + header: .init(take: 7, display: .init(id: 1, x: 0, y: 0, width: 800, height: 600))) + #expect(writer.append(.init(t: 1, type: .marker, label: "Kept"), durable: true)) + #expect(writer.append(.init(t: 3, type: .marker, label: "Too late"), durable: true)) + writer.close() + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{\"t\":1.5,\"type\":\"marker\"".utf8)) + try handle.close() + #expect(CaptureEventLog.markers(in: root, screenFile: renamed, through: 2).map(\.label) == ["Kept"]) + } + + @MainActor @Test func markerCacheUsesActualRenamedMovieAndInvalidatesChangedLog() async throws { + guard let ffmpeg = Setup.tool("ffmpeg") else { return } + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-marker-media-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let movieName = "take-07-welcome-screen.mov" + let source = root.appending(path: movieName) + let ff = Process() + ff.executableURL = URL(filePath: ffmpeg) + ff.arguments = ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", + "color=c=black:s=64x64:r=30:d=2", "-c:v", "mpeg4", "-y", source.path] + ff.standardOutput = FileHandle.nullDevice + ff.standardError = FileHandle.nullDevice + try ff.run() + ff.waitUntilExit() + #expect(ff.terminationStatus == 0) + + let take = Take(number: 7, kind: .screen, file: movieName, startedAt: .distantPast, duration: 2) + let meta = SessionMeta(title: "Marker navigation", createdAt: .distantPast, takes: [take]) + try Store.encoder.encode(meta).write(to: root.appending(path: "session.json")) + let file = CaptureEventLog.url(in: root, take: 7) + let writer = try CaptureEventLog.Writer(url: file, + header: .init(take: 7, display: .init(id: 1, x: 0, y: 0, width: 64, height: 64))) + #expect(writer.append(.init(t: 0.5, type: .marker, label: "Intro"), durable: true)) + writer.close() + + let segment = screen(7, 0, 2, fps: 30) + let draft = EditTimelineDraft(base: "takes/" + movieName, fps: 30, + source: [segment], segments: [segment]) + let cache = TimelineMarkerCache() + await cache.load(draft: draft, session: root) + #expect(cache.pins.count == 1) + #expect(cache.pins.first?.label == "Intro") + #expect(cache.pins.first?.outputTime == 0.5) + + let changed = try CaptureEventLog.Writer(url: file, + header: .init(take: 7, display: .init(id: 1, x: 0, y: 0, width: 64, height: 64))) + #expect(changed.append(.init(t: 1.5, type: .marker, label: "Recap"), durable: true)) + changed.close() + await cache.load(draft: draft, session: root) + #expect(cache.pins.map(\.label) == ["Intro", "Recap"]) + + let trimmed = EditTimelineDraft(base: draft.base, fps: 30, source: draft.source, + segments: [screen(7, 1, 2, fps: 30)]) + await cache.load(draft: trimmed, session: root) + #expect(cache.pins.map(\.label) == ["Recap"]) + cache.clear() + #expect(cache.pins.isEmpty) + + let outside = FileManager.default.temporaryDirectory.appending(path: "cutmark-marker-outside-\(UUID())") + defer { try? FileManager.default.removeItem(at: outside) } + try FileManager.default.moveItem(at: CaptureEventLog.directory(in: root), to: outside) + try FileManager.default.createSymbolicLink(at: CaptureEventLog.directory(in: root), + withDestinationURL: outside) + await cache.load(draft: draft, session: root) + #expect(cache.pins.isEmpty) + } + + @Test func oversizedLogLineIsSkippedAndReaderAndRulerAreBounded() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-marker-limits-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = CaptureEventLog.url(in: root, take: 3) + let writer = try CaptureEventLog.Writer(url: file, + header: .init(take: 3, display: .init(id: 1, x: 0, y: 0, width: 100, height: 100))) + #expect(writer.append(.init(t: 0.2, type: .marker, label: "First"))) + writer.close() + let handle = try FileHandle(forWritingTo: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(repeating: 0x58, count: 2 * 1024 * 1024)) + try handle.write(contentsOf: Data("\n".utf8)) + let encoder = JSONEncoder() + for i in 0..<720 { + let event = CaptureEvent(t: 1 + Double(i) / 1000, type: .marker, + label: "Marker \(i)") + try handle.write(contentsOf: encoder.encode(event)) + try handle.write(contentsOf: Data("\n".utf8)) + } + try handle.close() + let screenFile = root.appending(path: "take-03-screen.mov") + let accepted = CaptureEventLog.markers(in: root, screenFile: screenFile, through: 3) + #expect(accepted.count == 600) + #expect(accepted.first?.label == "First") + #expect(accepted.last?.label == "Marker 598") + #expect(CaptureEventLog.markers(in: root, screenFile: screenFile, through: 3, + shouldCancel: { true }).isEmpty) + let segment = screen(3, 0, 3, fps: 30) + let draft = EditTimelineDraft(base: "takes/take-03-screen.mov", fps: 30, + source: [segment], segments: [segment]) + let pins = TimelineMarkers.project(draft: draft, eventsByTake: [3: accepted]) + #expect(pins.count == 320) + #expect(pins.first?.label == "First") + #expect(pins.last?.label == "Marker 598") + } +} + +private let markerScreenshotDir = ProcessInfo.processInfo.environment["CUTMARK_MARKER_SHOTS"] + +@MainActor @Suite(.serialized) +struct TimelineMarkerShots { + @Test(.enabled(if: markerScreenshotDir != nil)) + func rulerWithMarkersInLightAndDarkAtFitAndZoom() throws { + _ = NSApplication.shared + let dir = try #require(markerScreenshotDir) + let url = URL(filePath: dir, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + let pins: [TimelineMarkerPin] = [ + .init(index: 0, take: 4, ordinal: 0, sourceTime: 1.2, outputTime: 1.2, label: "Introduction"), + .init(index: 1, take: 4, ordinal: 1, sourceTime: 2.5, outputTime: 3.75, label: "Chapter"), + .init(index: 2, take: 4, ordinal: 1, sourceTime: 2.5, outputTime: 7.25, label: "Chapter"), + ] + for dark in [true, false] { + for scale in [60.0, 100.0] { + let size = CGSize(width: 900, height: 150) + let view = VStack(alignment: .leading, spacing: 0) { + TimelineMarkerRuler(pins: pins, pixelsPerSecond: scale, duration: 9, + onSeek: { _ in }, onScrub: { _ in }) + Rectangle().fill(Theme.paper).overlay(alignment: .topLeading) { + Text("Screen · Camera · Screen").font(Theme.sans(12)) + .foregroundStyle(Theme.muted).padding(12) + } + } + .frame(width: size.width, height: size.height) + let host = NSHostingView(rootView: view) + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: NSRect(origin: CGPoint(x: -6000, y: -6000), size: size), + styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + window.orderFrontRegardless() + host.layoutSubtreeIfNeeded() + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: rep) + window.orderOut(nil) + let name = "timeline-markers-\(dark ? "dark" : "light")-\(Int(scale)).png" + try #require(rep.representation(using: .png, properties: [:])) + .write(to: url.appending(path: name)) + } + } + } +} From 70b028c8bd84f04103d05e52095f00a43e4e8ff4 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Thu, 22 Oct 2026 22:59:01 -0700 Subject: [PATCH 2/2] CI: allow cold Metal helper build to finish --- .github/workflows/ci.yml | 4 +++- build.sh | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bd9e0e..792a1db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/build.sh b/build.sh index f2d57bd..fc2ce7d 100755 --- a/build.sh +++ b/build.sh @@ -76,9 +76,12 @@ cp Info.plist "$APP/Contents/Info.plist" # file names and transcripts only. The model itself is never in the app: Cutmark downloads it. XCODE="${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null || true)}" if [[ -d embed && -d "$XCODE" ]] && DEVELOPER_DIR="$XCODE" xcrun metal --version >/dev/null 2>&1; then + echo "Building the semantic-search helper with Xcode and Metal..." + embed_build_started=$SECONDS (cd embed && DEVELOPER_DIR="$XCODE" xcodebuild -quiet -scheme CutmarkEmbed -configuration Release \ -destination 'platform=macOS,arch=arm64' -derivedDataPath .build-xcode build > .build-xcode.log 2>&1) \ || { tail -30 embed/.build-xcode.log; echo "The search helper did not build (embed/.build-xcode.log)." >&2; exit 1; } + echo "Semantic-search helper built in $((SECONDS - embed_build_started)) seconds." E=embed/.build-xcode/Build/Products/Release cp "$E/cutmark-embed" "$APP/Contents/MacOS/cutmark-embed" for b in "$E"/*.bundle; do cp -R "$b" "$APP/Contents/Resources/"; done