diff --git a/Reframed/Compositor/FrameRenderer+Helpers.swift b/Reframed/Compositor/FrameRenderer+Helpers.swift index 7eef115..1bc4f89 100644 --- a/Reframed/Compositor/FrameRenderer+Helpers.swift +++ b/Reframed/Compositor/FrameRenderer+Helpers.swift @@ -118,11 +118,9 @@ extension FrameRenderer { height: pixelRect.height * scaleY ) - let minDim = min(scaledRect.width, scaledRect.height) - return ResolvedCamera( rect: scaledRect, - cornerRadius: minDim * (region.cornerRadius / 100.0), + cornerRadius: region.cameraAspect.cornerRadius(in: scaledRect, percentage: region.cornerRadius), borderWidth: region.borderWidth * scale, borderColor: region.borderColor, shadow: region.shadow, diff --git a/Reframed/Compositor/VideoCompositor+InstructionBuilder.swift b/Reframed/Compositor/VideoCompositor+InstructionBuilder.swift index 49aacb5..1405904 100644 --- a/Reframed/Compositor/VideoCompositor+InstructionBuilder.swift +++ b/Reframed/Compositor/VideoCompositor+InstructionBuilder.swift @@ -160,9 +160,13 @@ extension VideoCompositor { }, cameraCornerRadius: { guard let rect = cameraRect else { return 0 } - let scaledW = rect.width * scaleX - let scaledH = rect.height * scaleY - return min(scaledW, scaledH) * (config.cameraCornerRadius / 100.0) + let scaledRect = CGRect( + x: rect.origin.x * scaleX, + y: rect.origin.y * scaleY, + width: rect.width * scaleX, + height: rect.height * scaleY + ) + return config.cameraAspect.cornerRadius(in: scaledRect, percentage: config.cameraCornerRadius) }(), cameraBorderWidth: config.cameraBorderWidth * scaleX, cameraBorderColor: config.cameraBorderColor.cgColor, diff --git a/Reframed/Editor/CameraRegionEditPopover.swift b/Reframed/Editor/CameraRegionEditPopover.swift index ccacd03..fabd73c 100644 --- a/Reframed/Editor/CameraRegionEditPopover.swift +++ b/Reframed/Editor/CameraRegionEditPopover.swift @@ -150,13 +150,15 @@ struct CameraRegionEditPopover: View { } } - SectionHeader(icon: "aspectratio", title: "Aspect Ratio") + SectionHeader(icon: "circle.square", title: "Camera Shape") - SegmentPicker( - items: CameraAspect.allCases, - label: { $0.label }, - selection: $localAspect - ) + ForEach(CameraAspect.pickerRows, id: \.self) { row in + SegmentPicker( + items: row, + label: { $0.label }, + selection: $localAspect + ) + } SectionHeader(icon: "paintbrush", title: "Style") @@ -171,8 +173,10 @@ struct CameraRegionEditPopover: View { label: "Radius", value: $localCornerRadius, range: 0...50, - formattedValue: "\(Int(localCornerRadius))%" + formattedValue: localAspect.isCircle ? "Circle" : "\(Int(localCornerRadius))%" ) + .disabled(localAspect.isCircle) + .opacity(localAspect.isCircle ? 0.5 : 1) SliderRow( label: "Shadow", diff --git a/Reframed/Editor/EditorState.swift b/Reframed/Editor/EditorState.swift index 5a63392..4e31019 100644 --- a/Reframed/Editor/EditorState.swift +++ b/Reframed/Editor/EditorState.swift @@ -238,6 +238,7 @@ final class EditorState { self.result = result self.playerController = SyncedPlayerController(result: result) self.projectName = result.screenVideoURL.deletingPathExtension().lastPathComponent + self.cameraAspect = ConfigService.shared.cameraAspect } func setup() async { diff --git a/Reframed/Editor/EditorTypes.swift b/Reframed/Editor/EditorTypes.swift index f3f2809..42d3b0b 100644 --- a/Reframed/Editor/EditorTypes.swift +++ b/Reframed/Editor/EditorTypes.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Foundation enum CanvasAspect: String, Codable, Sendable, CaseIterable, Identifiable { @@ -35,29 +36,53 @@ enum CameraAspect: String, Codable, Sendable, CaseIterable, Identifiable { case ratio16x9 case ratio1x1 case ratio4x3 + case ratio3x2 + case ratio2x3 case ratio9x16 + case circle var id: String { rawValue } + static let pickerRows: [[CameraAspect]] = [ + [.original, .circle], + [.ratio16x9, .ratio1x1, .ratio4x3], + [.ratio3x2, .ratio2x3, .ratio9x16], + ] + var label: String { switch self { case .original: "Original" case .ratio16x9: "16:9" case .ratio1x1: "1:1" case .ratio4x3: "4:3" + case .ratio3x2: "3:2" + case .ratio2x3: "2:3" case .ratio9x16: "9:16" + case .circle: "Circle" } } + var isCircle: Bool { + self == .circle + } + func heightToWidthRatio(webcamSize: CGSize) -> CGFloat { switch self { case .original: webcamSize.height / max(webcamSize.width, 1) case .ratio16x9: 9.0 / 16.0 case .ratio1x1: 1.0 case .ratio4x3: 3.0 / 4.0 + case .ratio3x2: 2.0 / 3.0 + case .ratio2x3: 3.0 / 2.0 case .ratio9x16: 16.0 / 9.0 + case .circle: 1.0 } } + + func cornerRadius(in rect: CGRect, percentage: CGFloat) -> CGFloat { + let minDimension = min(rect.width, rect.height) + return minDimension * ((isCircle ? 50 : percentage) / 100.0) + } } enum CameraFullscreenFillMode: String, Codable, Sendable, CaseIterable, Identifiable { diff --git a/Reframed/Editor/PropertiesPanel+CameraTab.swift b/Reframed/Editor/PropertiesPanel+CameraTab.swift index d1aaa45..1f2e80b 100644 --- a/Reframed/Editor/PropertiesPanel+CameraTab.swift +++ b/Reframed/Editor/PropertiesPanel+CameraTab.swift @@ -47,19 +47,21 @@ extension PropertiesPanel { .opacity(editorState.webcamEnabled ? 1 : 0.5) } - var cameraAspectRatioSection: some View { + var cameraShapeSection: some View { VStack(alignment: .leading, spacing: Layout.itemSpacing) { - SectionHeader(icon: "aspectratio", title: "Aspect Ratio") + SectionHeader(icon: "circle.square", title: "Camera Shape") - SegmentPicker( - items: CameraAspect.allCases, - label: { $0.label }, - selection: $editorState.cameraAspect - ) - .onChange(of: editorState.cameraAspect) { _, _ in - editorState.clampCameraPosition() + ForEach(CameraAspect.pickerRows, id: \.self) { row in + SegmentPicker( + items: row, + label: { $0.label }, + selection: $editorState.cameraAspect + ) } } + .onChange(of: editorState.cameraAspect) { _, _ in + editorState.clampCameraPosition() + } .disabled(!editorState.webcamEnabled) .opacity(editorState.webcamEnabled ? 1 : 0.5) } @@ -82,8 +84,10 @@ extension PropertiesPanel { label: "Radius", value: $editorState.cameraCornerRadius, range: 0...50, - formattedValue: "\(Int(editorState.cameraCornerRadius))%" + formattedValue: editorState.cameraAspect.isCircle ? "Circle" : "\(Int(editorState.cameraCornerRadius))%" ) + .disabled(editorState.cameraAspect.isCircle) + .opacity(editorState.cameraAspect.isCircle ? 0.5 : 1) SliderRow( label: "Shadow", diff --git a/Reframed/Editor/PropertiesPanel.swift b/Reframed/Editor/PropertiesPanel.swift index 34f11f5..52fbd9f 100644 --- a/Reframed/Editor/PropertiesPanel.swift +++ b/Reframed/Editor/PropertiesPanel.swift @@ -66,8 +66,8 @@ struct PropertiesPanel: View { backgroundSection case .camera: cameraSection + cameraShapeSection cameraPositionSection - cameraAspectRatioSection cameraStyleSection cameraBackgroundSection cameraFullscreenSection diff --git a/Reframed/Editor/VideoPreviewContainer+Interaction.swift b/Reframed/Editor/VideoPreviewContainer+Interaction.swift index 6737f5d..148653f 100644 --- a/Reframed/Editor/VideoPreviewContainer+Interaction.swift +++ b/Reframed/Editor/VideoPreviewContainer+Interaction.swift @@ -98,7 +98,7 @@ extension VideoPreviewContainer { webcamWrapper.frame = CGRect(x: x, y: bounds.height - y - h, width: w, height: h) if currentCameraShadow > 0 { let minDim = min(w, h) - let scaledRadius = minDim * (currentCameraCornerRadius / 100.0) + let scaledRadius = currentCameraAspect.cornerRadius(in: webcamWrapper.frame, percentage: currentCameraCornerRadius) let camBlur = minDim * currentCameraShadow / 2000.0 webcamWrapper.layer?.shadowRadius = camBlur webcamWrapper.layer?.shadowOpacity = 0.6 diff --git a/Reframed/Editor/VideoPreviewContainer+Layout.swift b/Reframed/Editor/VideoPreviewContainer+Layout.swift index e5026f2..b843cae 100644 --- a/Reframed/Editor/VideoPreviewContainer+Layout.swift +++ b/Reframed/Editor/VideoPreviewContainer+Layout.swift @@ -176,8 +176,7 @@ extension VideoPreviewContainer { height: pipFrame.height + (fsTargetRect.height - pipFrame.height) * p ) - let pipMinDim = min(pipW, pipH) - let pipRadius = pipMinDim * (currentCameraCornerRadius / 100.0) + let pipRadius = currentCameraAspect.cornerRadius(in: pipFrame, percentage: currentCameraCornerRadius) let interpRadius = pipRadius * (1.0 - p) let pipBorder = currentCameraBorderWidth * min(scaleX, scaleY) let interpBorder = pipBorder * (1.0 - p) @@ -289,10 +288,8 @@ extension VideoPreviewContainer { height: defFrame.height + (customFrame.height - defFrame.height) * p ) - let defMinDim = min(defW, defH) - let defRadius = defMinDim * (defaultPipCornerRadius / 100.0) - let customMinDim = min(w, h) - let customRadius = customMinDim * (currentCameraCornerRadius / 100.0) + let defRadius = defaultPipCameraAspect.cornerRadius(in: defFrame, percentage: defaultPipCornerRadius) + let customRadius = currentCameraAspect.cornerRadius(in: customFrame, percentage: currentCameraCornerRadius) let interpRadius = defRadius + (customRadius - defRadius) * p let defBorder = defaultPipBorderWidth * min(scaleX, scaleY) @@ -342,11 +339,11 @@ extension VideoPreviewContainer { return } + let webcamFrame = CGRect(x: x, y: bounds.height - y - h, width: w, height: h) let minDim = min(w, h) - let scaledRadius = minDim * (currentCameraCornerRadius / 100.0) + let scaledRadius = currentCameraAspect.cornerRadius(in: webcamFrame, percentage: currentCameraCornerRadius) let scaledBorder = currentCameraBorderWidth * min(scaleX, scaleY) - let webcamFrame = CGRect(x: x, y: bounds.height - y - h, width: w, height: h) webcamWrapper.frame = webcamFrame webcamView.frame = webcamWrapper.bounds webcamView.layer?.cornerRadius = scaledRadius diff --git a/Reframed/Recording/WebcamPreviewWindow.swift b/Reframed/Recording/WebcamPreviewWindow.swift index c0a2e19..19ba20f 100644 --- a/Reframed/Recording/WebcamPreviewWindow.swift +++ b/Reframed/Recording/WebcamPreviewWindow.swift @@ -9,16 +9,31 @@ final class WebcamPreviewWindow { nonisolated(unsafe) private var moveObserver: NSObjectProtocol? private var appearanceObserver: NSKeyValueObservation? - private let videoWidth: CGFloat = 270 - private let videoHeight: CGFloat = 202 - private let cornerRadius: CGFloat = 60 + private let baseVideoWidth: CGFloat = 270 + private let cornerRadiusPercent: CGFloat = 30 + private var videoWidth: CGFloat = 270 + private var videoHeight: CGFloat = 202 + private var cameraAspect: CameraAspect = .original + private var webcamSize: CGSize? + + private var cornerRadius: CGFloat { + let rect = CGRect(origin: .zero, size: CGSize(width: videoWidth, height: videoHeight)) + if cameraAspect.isCircle { + return cameraAspect.cornerRadius(in: rect, percentage: 50) + } + return min(videoWidth, videoHeight) * cornerRadiusPercent / 100 + } private var totalWidth: CGFloat { videoWidth } private var totalHeight: CGFloat { videoHeight } - func showLoading() { + func showLoading(cameraAspect: CameraAspect = ConfigService.shared.cameraAspect, webcamSize: CGSize? = nil) { + configureStyle(cameraAspect: cameraAspect, webcamSize: webcamSize) + if panel == nil { createPanel() + } else { + resizePanelForCurrentStyle() } previewLayer?.removeFromSuperlayer() @@ -26,6 +41,7 @@ final class WebcamPreviewWindow { loadingView?.removeFromSuperview() guard let contentView = panel?.contentView else { return } + contentView.subviews.forEach { $0.removeFromSuperview() } let container = NSView(frame: NSRect(origin: .zero, size: NSSize(width: videoWidth, height: videoHeight))) container.wantsLayer = true @@ -53,15 +69,26 @@ final class WebcamPreviewWindow { panel?.orderFrontRegardless() } - func show(captureSession: AVCaptureSession) { + func show( + captureSession: AVCaptureSession, + cameraAspect: CameraAspect = ConfigService.shared.cameraAspect, + webcamSize: CGSize? = nil + ) { + configureStyle(cameraAspect: cameraAspect, webcamSize: webcamSize) + if panel == nil { createPanel() + } else { + resizePanelForCurrentStyle() } previewLayer?.removeFromSuperlayer() previewLayer = nil guard let contentView = panel?.contentView else { return } + contentView.subviews + .filter { $0 !== loadingView } + .forEach { $0.removeFromSuperview() } let videoView = NSView(frame: NSRect(origin: .zero, size: NSSize(width: videoWidth, height: videoHeight))) videoView.wantsLayer = true @@ -85,9 +112,13 @@ final class WebcamPreviewWindow { } } - func showError(_ message: String) { + func showError(_ message: String, cameraAspect: CameraAspect = ConfigService.shared.cameraAspect, webcamSize: CGSize? = nil) { + configureStyle(cameraAspect: cameraAspect, webcamSize: webcamSize) + if panel == nil { createPanel() + } else { + resizePanelForCurrentStyle() } previewLayer?.removeFromSuperlayer() @@ -96,6 +127,7 @@ final class WebcamPreviewWindow { loadingView = nil guard let contentView = panel?.contentView else { return } + contentView.subviews.forEach { $0.removeFromSuperview() } let container = NSView(frame: NSRect(origin: .zero, size: NSSize(width: videoWidth, height: videoHeight))) container.wantsLayer = true @@ -122,6 +154,11 @@ final class WebcamPreviewWindow { panel?.orderFrontRegardless() } + func updateStyle(cameraAspect: CameraAspect, webcamSize: CGSize? = nil) { + configureStyle(cameraAspect: cameraAspect, webcamSize: webcamSize) + resizePanelForCurrentStyle() + } + func hide() { panel?.orderOut(nil) } @@ -190,6 +227,37 @@ final class WebcamPreviewWindow { } } + private func configureStyle(cameraAspect: CameraAspect, webcamSize: CGSize?) { + self.cameraAspect = cameraAspect + if let webcamSize { + self.webcamSize = webcamSize + } + + let sourceSize = self.webcamSize ?? CGSize(width: 4, height: 3) + let ratio = cameraAspect.heightToWidthRatio(webcamSize: sourceSize) + videoWidth = baseVideoWidth + videoHeight = round(videoWidth * ratio) + } + + private func resizePanelForCurrentStyle() { + guard let panel else { return } + let newSize = NSSize(width: totalWidth, height: totalHeight) + let oldFrame = panel.frame + let origin = CGPoint(x: oldFrame.maxX - newSize.width, y: oldFrame.origin.y) + panel.setFrame(NSRect(origin: origin, size: newSize), display: true) + + guard let contentView = panel.contentView else { return } + contentView.frame = NSRect(origin: .zero, size: newSize) + contentView.layer?.cornerRadius = cornerRadius + + for subview in contentView.subviews { + subview.frame = contentView.bounds + subview.layer?.cornerRadius = cornerRadius + } + + previewLayer?.frame = contentView.bounds + } + private func updateColors() { if let container = loadingView { container.layer?.backgroundColor = ReframedColors.backgroundNS.cgColor diff --git a/Reframed/State/ConfigService.swift b/Reframed/State/ConfigService.swift index ca10869..464dfed 100644 --- a/Reframed/State/ConfigService.swift +++ b/Reframed/State/ConfigService.swift @@ -55,6 +55,11 @@ final class ConfigService { set { data.cameraMaximumResolution = newValue; save() } } + var cameraAspect: CameraAspect { + get { CameraAspect(rawValue: data.cameraAspect) ?? .original } + set { data.cameraAspect = newValue.rawValue; save() } + } + var projectFolder: String { get { data.projectFolder } set { data.projectFolder = newValue; save() } @@ -174,6 +179,7 @@ private struct ConfigData: Codable { var captureSystemAudio: Bool = false var cameraDeviceId: String? = nil var cameraMaximumResolution: String = "1080p" + var cameraAspect: String = CameraAspect.original.rawValue var projectFolder: String = "~/Reframed" var retinaCapture: Bool = false var dimOuterArea: Bool = true diff --git a/Reframed/State/RecordingOptions.swift b/Reframed/State/RecordingOptions.swift index 8069165..82de253 100644 --- a/Reframed/State/RecordingOptions.swift +++ b/Reframed/State/RecordingOptions.swift @@ -88,6 +88,10 @@ final class RecordingOptions { didSet { ConfigService.shared.showRecordingPreview = showRecordingPreview } } + var cameraAspect: CameraAspect { + didSet { ConfigService.shared.cameraAspect = cameraAspect } + } + var hdrCapture: Bool { didSet { ConfigService.shared.hdrCapture = hdrCapture } } @@ -127,6 +131,7 @@ final class RecordingOptions { dimOuterArea = config.dimOuterArea hideCameraPreviewWhileRecording = config.hideCameraPreviewWhileRecording showRecordingPreview = config.showRecordingPreview + cameraAspect = config.cameraAspect hdrCapture = config.hdrCapture let savedDeviceId = config.audioDeviceId diff --git a/Reframed/State/SessionState+Camera.swift b/Reframed/State/SessionState+Camera.swift index b196fe7..1dbaff1 100644 --- a/Reframed/State/SessionState+Camera.swift +++ b/Reframed/State/SessionState+Camera.swift @@ -21,7 +21,7 @@ extension SessionState { cameraPreviewState = .starting let previewWindow = WebcamPreviewWindow() - previewWindow.showLoading() + previewWindow.showLoading(cameraAspect: options.cameraAspect) webcamPreviewWindow = previewWindow Task { @@ -43,7 +43,11 @@ extension SessionState { cameraPreviewState = .previewing if let session = webcam.captureSession { - previewWindow.show(captureSession: session) + previewWindow.show( + captureSession: session, + cameraAspect: options.cameraAspect, + webcamSize: CGSize(width: info.width, height: info.height) + ) } logger.info("Camera preview started: \(info.width)x\(info.height)") } catch { @@ -75,11 +79,22 @@ extension SessionState { func showCameraPreviewIfNeeded(from box: SendableBox?) { if let camSession = box?.session { let previewWindow = WebcamPreviewWindow() - previewWindow.show(captureSession: camSession) + previewWindow.show( + captureSession: camSession, + cameraAspect: options.cameraAspect, + webcamSize: verifiedCameraInfo.map { CGSize(width: $0.width, height: $0.height) } + ) if options.hideCameraPreviewWhileRecording { previewWindow.hide() } self.webcamPreviewWindow = previewWindow } } + + func updateCameraPreviewShape() { + webcamPreviewWindow?.updateStyle( + cameraAspect: options.cameraAspect, + webcamSize: verifiedCameraInfo.map { CGSize(width: $0.width, height: $0.height) } + ) + } } diff --git a/Reframed/UI/CaptureToolbar+ModeSelection.swift b/Reframed/UI/CaptureToolbar+ModeSelection.swift index 8035368..9a79c8e 100644 --- a/Reframed/UI/CaptureToolbar+ModeSelection.swift +++ b/Reframed/UI/CaptureToolbar+ModeSelection.swift @@ -169,8 +169,10 @@ extension CaptureToolbar { .buttonStyle(PlainCustomButtonStyle()) .hoverEffect(id: "btn.options") .popover(isPresented: $showOptions, arrowEdge: .bottom) { - OptionsPopover(options: session.options) - .presentationBackground(ReframedColors.backgroundPopover) + OptionsPopover(options: session.options) { + session.updateCameraPreviewShape() + } + .presentationBackground(ReframedColors.backgroundPopover) } ToolbarDivider() diff --git a/Reframed/UI/OptionsPopover.swift b/Reframed/UI/OptionsPopover.swift index d5a4aa4..c572195 100644 --- a/Reframed/UI/OptionsPopover.swift +++ b/Reframed/UI/OptionsPopover.swift @@ -2,6 +2,7 @@ import SwiftUI struct OptionsPopover: View { @Bindable var options: RecordingOptions + var onCameraAspectChange: (() -> Void)? @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -75,6 +76,26 @@ struct OptionsPopover: View { } } + if options.selectedCamera != nil { + Divider() + .background(ReframedColors.divider) + .padding(.vertical, 4) + + SectionHeader(title: "Camera Shape") + + VStack(alignment: .leading, spacing: Layout.compactSpacing) { + ForEach(CameraAspect.pickerRows, id: \.self) { row in + SegmentPicker( + items: row, + label: { $0.label }, + selection: $options.cameraAspect + ) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + Divider() .background(ReframedColors.divider) .padding(.vertical, 4) @@ -126,5 +147,8 @@ struct OptionsPopover: View { .padding(.vertical, 8) .frame(minWidth: 280) .popoverContainerStyle() + .onChange(of: options.cameraAspect) { _, _ in + onCameraAspectChange?() + } } }