Skip to content
Closed
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
32 changes: 30 additions & 2 deletions CameraController.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
objects = {

/* Begin PBXBuildFile section */
E609236DA95941C68357DC7C /* CropStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB965BC529947D098529825 /* CropStateTests.swift */; };
42E1D2CB78C745FD82DD2B4D /* CropState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8601BA2183BC404195E55B9B /* CropState.swift */; };
C9EBB59564D6436D9E70E4FB /* CropFrameProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348EE08754A14498B44D71D5 /* CropFrameProcessor.swift */; };
ACAE09A704AC423593DBD0EE /* CroppedFrameRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C558E9CC74919944C89BE /* CroppedFrameRenderer.swift */; };
F7BEDBD7090D448E8F587FE4 /* CropStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37538EBD6ED84E13BA88B825 /* CropStateManager.swift */; };
F42460312A5A104F009108C2 /* Toggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42460302A5A104F009108C2 /* Toggle.swift */; };
F42460332A5A299B009108C2 /* GenericControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42460322A5A299B009108C2 /* GenericControl.swift */; };
F42460352A5A65A0009108C2 /* BrightnessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42460342A5A65A0009108C2 /* BrightnessView.swift */; };
Expand Down Expand Up @@ -156,6 +161,11 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
7AB965BC529947D098529825 /* CropStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CropStateTests.swift; sourceTree = "<group>"; };
8601BA2183BC404195E55B9B /* CropState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CropState.swift; sourceTree = "<group>"; };
348EE08754A14498B44D71D5 /* CropFrameProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CropFrameProcessor.swift; sourceTree = "<group>"; };
6A0C558E9CC74919944C89BE /* CroppedFrameRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CroppedFrameRenderer.swift; sourceTree = "<group>"; };
37538EBD6ED84E13BA88B825 /* CropStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CropStateManager.swift; sourceTree = "<group>"; };
F42460302A5A104F009108C2 /* Toggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toggle.swift; sourceTree = "<group>"; };
F42460322A5A299B009108C2 /* GenericControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericControl.swift; sourceTree = "<group>"; };
F42460342A5A65A0009108C2 /* BrightnessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -395,6 +405,7 @@
F42D42D527A0E3D500107DFF /* StatusBarManager.swift */,
F42D42D727A0E92100107DFF /* WindowManager.swift */,
F469CF4624DD0D9F0092C7F9 /* ProfileManager.swift */,
37538EBD6ED84E13BA88B825 /* CropStateManager.swift */,
);
path = Managers;
sourceTree = "<group>";
Expand All @@ -418,7 +429,8 @@
F47D07DC24C4104600E44A22 /* CameraControllerTests.xctest */,
F4D4C6E124CBE7A000581E05 /* Helper.app */,
F4508ACC2A43E477003C44F1 /* UVC.framework */,
);

7AB965BC529947D098529825 /* CropStateTests.swift */,);
name = Products;
sourceTree = "<group>";
};
Expand All @@ -434,6 +446,7 @@
F47D07EB24C4117A00E44A22 /* Views */,
F47D07D024C4104600E44A22 /* Preview Content */,
F42D42D927A0F0B200107DFF /* Vendored */,
1A2B3C4D5E6F708192A3B4C5 /* Crop */,
);
path = CameraController;
sourceTree = "<group>";
Expand Down Expand Up @@ -650,6 +663,16 @@
path = Properties;
sourceTree = "<group>";
};
1A2B3C4D5E6F708192A3B4C5 /* Crop */ = {
isa = PBXGroup;
children = (
8601BA2183BC404195E55B9B /* CropState.swift */,
348EE08754A14498B44D71D5 /* CropFrameProcessor.swift */,
6A0C558E9CC74919944C89BE /* CroppedFrameRenderer.swift */,
);
path = Crop;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -878,6 +901,10 @@
F4508AEB2A43E4D7003C44F1 /* CameraInformation.swift in Sources */,
F4508AEA2A43E4D3003C44F1 /* USBDevice.swift in Sources */,
F4508AE72A43E4C5003C44F1 /* UVCDeviceProperties.swift in Sources */,
42E1D2CB78C745FD82DD2B4D /* CropState.swift in Sources */,
C9EBB59564D6436D9E70E4FB /* CropFrameProcessor.swift in Sources */,
ACAE09A704AC423593DBD0EE /* CroppedFrameRenderer.swift in Sources */,
F7BEDBD7090D448E8F587FE4 /* CropStateManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -952,7 +979,8 @@
files = (
F47D080A24C50E1C00E44A22 /* StringTests.swift in Sources */,
F47D07F424C412F800E44A22 /* DevicesManagerTests.swift in Sources */,
);

E609236DA95941C68357DC7C /* CropStateTests.swift in Sources */,);
runOnlyForDeploymentPostprocessing = 0;
};
F4D4C6DD24CBE7A000581E05 /* Sources */ = {
Expand Down
105 changes: 105 additions & 0 deletions CameraController/Crop/CropFrameProcessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//
// CropFrameProcessor.swift
// CameraController
//
// Created by Itay Brenner on 2/15/26.
// Copyright © 2026 Itaysoft. All rights reserved.
//

import Foundation
import AVFoundation
import CoreImage

/// Processes video frames and applies crop filter
final class CropFrameProcessor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
private let context: CIContext
private let cropState: CropState
private var frameCounter: Int = 0

/// Callback for processed frames (called on background queue, dispatch to main for UI)
var onProcessedFrame: ((CIImage, CGSize) -> Void)?

init(cropState: CropState) {
self.cropState = cropState

// Create Metal-backed context with no intermediate caching
let options: [CIContextOption: Any] = [
.cacheIntermediates: false
]

if let metalDevice = MTLCreateSystemDefaultDevice() {
self.context = CIContext(mtlDevice: metalDevice, options: options)
} else {
// Fallback to CPU context if Metal unavailable
self.context = CIContext(options: options)
}

super.init()
}

func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
// Skip frames during adjustment for performance
if cropState.isAdjusting {
frameCounter += 1
if frameCounter % 2 != 0 {
return
}
} else {
frameCounter = 0
}

// Extract pixel buffer
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}

// Create CIImage from pixel buffer (zero-copy)
var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
let originalSize = ciImage.extent.size

// If crop is disabled or full, pass through unchanged
guard cropState.isEnabled && !cropState.cropRect.isFull else {
onProcessedFrame?(ciImage, originalSize)
return
}

// Convert crop rect from top-left origin to CIImage's bottom-left origin
let cropRect = cropState.cropRect.pixelRect(for: originalSize)
let flippedY = originalSize.height - cropRect.origin.y - cropRect.height
let ciCropRect = CGRect(
x: cropRect.origin.x,
y: flippedY,
width: cropRect.width,
height: cropRect.height
)

// Apply crop filter
ciImage = ciImage.cropped(to: ciCropRect)

// Translate origin to (0,0) for rendering
ciImage = ciImage.transformed(by: CGAffineTransform(
translationX: -ciCropRect.origin.x,
y: -ciCropRect.origin.y
))

// Call callback with processed image
onProcessedFrame?(ciImage, ciCropRect.size)
}

func captureOutput(
_ output: AVCaptureOutput,
didDrop sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
// Expected under load, no-op
}

/// Render CIImage to CGImage (CPU readback, only for export)
func renderCGImage(from ciImage: CIImage) -> CGImage? {
return context.createCGImage(ciImage, from: ciImage.extent)
}
}
175 changes: 175 additions & 0 deletions CameraController/Crop/CropState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//
// CropState.swift
// CameraController
//
// Created by Itay Brenner on 2/15/26.
// Copyright © 2026 Itaysoft. All rights reserved.
//

import Foundation
import CoreGraphics
import Combine

// MARK: - NormalizedCropRect

/// A crop rectangle in normalized coordinates (0.0-1.0), with origin at top-left
struct NormalizedCropRect: Codable, Equatable {
var originX: CGFloat
var originY: CGFloat
var width: CGFloat
var height: CGFloat

/// Full frame crop rect (no cropping)
static let full = NormalizedCropRect(originX: 0, originY: 0, width: 1, height: 1)

/// Minimum dimension as fraction of frame (5%)
private static let minDimension: CGFloat = 0.05

/// Check if this is the full frame (no crop)
var isFull: Bool {
return originX == 0 && originY == 0 && width == 1 && height == 1
}

/// Convert to CGRect
var cgRect: CGRect {
return CGRect(x: originX, y: originY, width: width, height: height)
}

/// Convert to pixel coordinates for given frame size
func pixelRect(for size: CGSize) -> CGRect {
return CGRect(
x: originX * size.width,
y: originY * size.height,
width: width * size.width,
height: height * size.height
)
}

/// Create from pixel rect and frame size
static func fromPixelRect(_ rect: CGRect, frameSize: CGSize) -> NormalizedCropRect {
guard frameSize.width > 0 && frameSize.height > 0 else {
return .full
}

return NormalizedCropRect(
originX: rect.origin.x / frameSize.width,
originY: rect.origin.y / frameSize.height,
width: rect.width / frameSize.width,
height: rect.height / frameSize.height
)
}

/// Clamp to valid range [0-1] and enforce minimum dimensions
func clamped() -> NormalizedCropRect {
// Clamp dimensions first, ensuring minimum size
let clampedWidth = max(min(width, 1.0), Self.minDimension)
let clampedHeight = max(min(height, 1.0), Self.minDimension)

// Clamp origin, ensuring rect stays within bounds
let maxX = 1.0 - clampedWidth
let maxY = 1.0 - clampedHeight
let clampedX = max(min(originX, maxX), 0)
let clampedY = max(min(originY, maxY), 0)

return NormalizedCropRect(
originX: clampedX,
originY: clampedY,
width: clampedWidth,
height: clampedHeight
)
}
}

// MARK: - AspectRatioPreset

/// Common aspect ratio presets for cropping
enum AspectRatioPreset: String, CaseIterable, Identifiable, Codable {
case free = "Free"
case ratio16x9 = "16:9"
case ratio4x3 = "4:3"
case ratio1x1 = "1:1"
case ratio9x16 = "9:16"

var id: String { rawValue }

/// The aspect ratio value (width / height), or nil for free
var ratio: CGFloat? {
switch self {
case .free:
return nil
case .ratio16x9:
return 16.0 / 9.0
case .ratio4x3:
return 4.0 / 3.0
case .ratio1x1:
return 1.0
case .ratio9x16:
return 9.0 / 16.0
}
}
}

// MARK: - StorableCropState

/// Codable snapshot of crop state for persistence
struct StorableCropState: Codable {
let isEnabled: Bool
let cropRect: NormalizedCropRect
let aspectRatio: AspectRatioPreset
}

// MARK: - CropState

/// ObservableObject managing crop configuration for a specific camera
final class CropState: ObservableObject {
/// Whether cropping is enabled
@Published var isEnabled: Bool = false

/// The crop rectangle in normalized coordinates
@Published var cropRect: NormalizedCropRect = .full

/// The aspect ratio constraint
@Published var aspectRatio: AspectRatioPreset = .free

/// Whether user is actively adjusting the crop (used for frame skipping)
@Published var isAdjusting: Bool = false

/// The camera device ID this crop state is associated with
let deviceID: String

init(deviceID: String) {
self.deviceID = deviceID
}

/// Resolve the effective crop rect for a given frame size
/// Returns full frame rect when disabled
func resolve(for frameSize: CGSize) -> CGRect {
guard isEnabled && !cropRect.isFull else {
return CGRect(origin: .zero, size: frameSize)
}

return cropRect.pixelRect(for: frameSize)
}

/// Reset crop to full frame and free aspect ratio
func reset() {
cropRect = .full
aspectRatio = .free
}

/// Create a storable snapshot for persistence
func toStorable() -> StorableCropState {
return StorableCropState(
isEnabled: isEnabled,
cropRect: cropRect,
aspectRatio: aspectRatio
)
}

/// Apply stored state
func apply(_ stored: StorableCropState) {
isEnabled = stored.isEnabled
cropRect = stored.cropRect
aspectRatio = stored.aspectRatio
}
}
Loading