Skip to content

Commit f736879

Browse files
committed
feat(capture): enhance capturing screen with options like superscale, codec selection and some other stuff
1 parent 15875a2 commit f736879

20 files changed

+293
-57
lines changed

Reframed/Compositor/VideoCompositor.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,15 @@ enum VideoCompositor {
128128
let hasWebcam = result.webcamVideoURL != nil
129129
let hasCursor = cursorSnapshot != nil
130130
let hasZoom = zoomTimeline != nil
131+
let sourceCodecMatchesExport: Bool = {
132+
switch result.captureQuality {
133+
case .veryHigh: return exportSettings.codec == .proRes4444
134+
case .high: return exportSettings.codec == .proRes422
135+
case .standard: return exportSettings.codec == .h264
136+
}
137+
}()
131138
let needsReencode =
132-
exportSettings.codec != .h264 || exportSettings.resolution != .original
139+
!sourceCodecMatchesExport || exportSettings.resolution != .original
133140
|| exportSettings.fps != .original
134141
let needsCompositor =
135142
hasVisualEffects || hasWebcam || needsReencode || hasCursor || hasZoom

Reframed/Editor/EditorState.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,8 @@ final class EditorState {
785785
cursorMetadataURL: result.cursorMetadataURL,
786786
screenSize: result.screenSize,
787787
webcamSize: nil,
788-
fps: result.fps
788+
fps: result.fps,
789+
captureQuality: result.captureQuality
789790
)
790791
}
791792

Reframed/Editor/EditorTopBar.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ struct EditorTopBar: View {
6565
}
6666
.buttonStyle(.plain)
6767
.disabled(editorState.isExporting)
68-
.opacity(editorState.isExporting ? 0.4 : 1.0)
68+
.opacity(editorState.isExporting ? 0.5 : 1.0)
6969
}
7070
}
7171
.padding(.leading, 16)

Reframed/Editor/EditorView+TransportBar.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ extension EditorView {
1818

1919
IconButton(
2020
systemName: "minus.magnifyingglass",
21-
color: timelineZoom > 1.0 ? ReframedColors.primaryText : ReframedColors.tertiaryText
21+
color: timelineZoom > 1.0 ? ReframedColors.primaryText : ReframedColors.disabledText
2222
) {
2323
timelineZoom = max(1.0, timelineZoom / 1.5)
2424
baseZoom = timelineZoom
@@ -27,7 +27,7 @@ extension EditorView {
2727

2828
IconButton(
2929
systemName: "plus.magnifyingglass",
30-
color: timelineZoom < 30.0 ? ReframedColors.primaryText : ReframedColors.tertiaryText
30+
color: timelineZoom < 30.0 ? ReframedColors.primaryText : ReframedColors.disabledText
3131
) {
3232
timelineZoom = min(30.0, timelineZoom * 1.5)
3333
baseZoom = timelineZoom
@@ -36,7 +36,7 @@ extension EditorView {
3636

3737
IconButton(
3838
systemName: "1.magnifyingglass",
39-
color: timelineZoom > 1.0 ? ReframedColors.primaryText : ReframedColors.tertiaryText
39+
color: timelineZoom > 1.0 ? ReframedColors.primaryText : ReframedColors.disabledText
4040
) {
4141
timelineZoom = 1.0
4242
baseZoom = 1.0
@@ -53,14 +53,14 @@ extension EditorView {
5353

5454
IconButton(
5555
systemName: "arrow.uturn.backward",
56-
color: editorState.history.canUndo ? ReframedColors.primaryText : ReframedColors.tertiaryText,
56+
color: editorState.history.canUndo ? ReframedColors.primaryText : ReframedColors.disabledText,
5757
action: { editorState.undo() }
5858
)
5959
.disabled(!editorState.history.canUndo)
6060

6161
IconButton(
6262
systemName: "arrow.uturn.forward",
63-
color: editorState.history.canRedo ? ReframedColors.primaryText : ReframedColors.tertiaryText,
63+
color: editorState.history.canRedo ? ReframedColors.primaryText : ReframedColors.disabledText,
6464
action: { editorState.redo() }
6565
)
6666
.disabled(!editorState.history.canRedo)

Reframed/Editor/EditorView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ struct EditorView: View {
171171
in: RoundedRectangle(cornerRadius: 8)
172172
)
173173
.contentShape(Rectangle())
174-
.opacity(disabled ? 0.35 : 1)
174+
.opacity(disabled ? 0.45 : 1)
175175
}
176176
.buttonStyle(.plain)
177177
.hoverEffect(id: "tab.\(tab.rawValue)")

Reframed/Editor/PropertiesPanel.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,64 @@ struct PropertiesPanel: View {
157157
VStack(spacing: Layout.compactSpacing) {
158158
infoRow("Resolution", value: "\(Int(editorState.result.screenSize.width))x\(Int(editorState.result.screenSize.height))")
159159
infoRow("FPS", value: "\(editorState.result.fps)")
160+
infoRow("Codec", value: codecLabel(editorState.result.captureQuality))
160161
infoRow("Duration", value: formatDuration(editorState.duration))
161162

163+
if let mode = editorState.project?.metadata.captureMode, mode != .none {
164+
infoRow("Capture Mode", value: captureModeLabel(mode))
165+
}
166+
167+
infoRow("File Size", value: formattedFileSize(url: editorState.result.screenVideoURL))
168+
162169
if let ws = editorState.result.webcamSize {
163170
infoRow("Webcam", value: "\(Int(ws.width))x\(Int(ws.height))")
164171
}
165172

166173
infoRow("System Audio", value: editorState.result.systemAudioURL != nil ? "Yes" : "No")
167174
infoRow("Microphone", value: editorState.result.microphoneAudioURL != nil ? "Yes" : "No")
168175
infoRow("Cursor Data", value: editorState.cursorMetadataProvider != nil ? "Yes" : "No")
176+
177+
if let date = editorState.project?.metadata.createdAt {
178+
infoRow("Recorded", value: formattedDate(date))
179+
}
169180
}
170181
}
171182
}
172183

184+
private func codecLabel(_ quality: CaptureQuality) -> String {
185+
switch quality {
186+
case .standard: "H.264"
187+
case .high: "ProRes 422"
188+
case .veryHigh: "ProRes 4444"
189+
}
190+
}
191+
192+
private func captureModeLabel(_ mode: CaptureMode) -> String {
193+
switch mode {
194+
case .none: "None"
195+
case .entireScreen: "Entire Screen"
196+
case .selectedWindow: "Window"
197+
case .selectedArea: "Area"
198+
case .device: "iOS Device"
199+
}
200+
}
201+
202+
private func formattedFileSize(url: URL) -> String {
203+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
204+
let size = attrs[.size] as? Int64
205+
else {
206+
return ""
207+
}
208+
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
209+
}
210+
211+
private func formattedDate(_ date: Date) -> String {
212+
let formatter = DateFormatter()
213+
formatter.dateStyle = .medium
214+
formatter.timeStyle = .short
215+
return formatter.string(from: date)
216+
}
217+
173218
private func infoRow(_ label: String, value: String) -> some View {
174219
HStack {
175220
Text(label)

Reframed/Project/ProjectMetadata.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct ProjectMetadata: Codable, Sendable {
1313
var hasCursorMetadata: Bool = false
1414
var hasWebcam: Bool = false
1515
var captureMode: CaptureMode?
16+
var captureQuality: String? = nil
1617
var editorState: EditorStateData?
1718
}
1819

Reframed/Project/ReframedProject.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ struct ReframedProject: Sendable {
1010
}
1111

1212
var screenVideoURL: URL {
13-
bundleURL.appendingPathComponent("screen.mp4")
13+
let movURL = bundleURL.appendingPathComponent("screen.mov")
14+
if FileManager.default.fileExists(atPath: movURL.path) {
15+
return movURL
16+
}
17+
return bundleURL.appendingPathComponent("screen.mp4")
1418
}
1519

1620
var webcamVideoURL: URL? {
@@ -51,7 +55,8 @@ struct ReframedProject: Sendable {
5155
cursorMetadataURL: cursorMetadataURL,
5256
screenSize: metadata.screenSize.cgSize,
5357
webcamSize: metadata.webcamSize?.cgSize,
54-
fps: metadata.fps
58+
fps: metadata.fps,
59+
captureQuality: CaptureQuality(rawValue: metadata.captureQuality ?? "standard") ?? .standard
5560
)
5661
}
5762

@@ -68,7 +73,8 @@ struct ReframedProject: Sendable {
6873
let bundleURL = directory.appendingPathComponent(bundleName)
6974
try fm.createDirectory(at: bundleURL, withIntermediateDirectories: true)
7075

71-
try fm.moveItem(at: result.screenVideoURL, to: bundleURL.appendingPathComponent("screen.mp4"))
76+
let screenExt = result.screenVideoURL.pathExtension
77+
try fm.moveItem(at: result.screenVideoURL, to: bundleURL.appendingPathComponent("screen.\(screenExt)"))
7278

7379
if let webcamURL = result.webcamVideoURL {
7480
try fm.moveItem(at: webcamURL, to: bundleURL.appendingPathComponent("webcam.mp4"))
@@ -97,7 +103,8 @@ struct ReframedProject: Sendable {
97103
hasMicrophoneAudio: result.microphoneAudioURL != nil,
98104
hasCursorMetadata: result.cursorMetadataURL != nil,
99105
hasWebcam: result.webcamVideoURL != nil,
100-
captureMode: captureMode
106+
captureMode: captureMode,
107+
captureQuality: result.captureQuality.rawValue
101108
)
102109

103110
let encoder = JSONEncoder()

Reframed/Recording/FileManager+Reframed.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ extension FileManager {
1717
reframedTempDir().appendingPathComponent("reframed-\(timestamp()).mp4")
1818
}
1919

20-
func tempVideoURL() -> URL {
21-
reframedTempDir().appendingPathComponent("video-\(timestamp()).mp4")
20+
func tempVideoURL(captureQuality: CaptureQuality = .standard) -> URL {
21+
let ext = captureQuality.isProRes ? "mov" : "mp4"
22+
return reframedTempDir().appendingPathComponent("video-\(timestamp()).\(ext)")
2223
}
2324

2425
func tempWebcamURL() -> URL {

Reframed/Recording/RecordingCoordinator.swift

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ actor RecordingCoordinator {
2626
private var webcamPixelW: Int = 0
2727
private var webcamPixelH: Int = 0
2828
private var recordingFPS: Int = 60
29+
private var captureQualityUsed: CaptureQuality = .standard
2930

3031
func startRecording(
3132
target: CaptureTarget,
@@ -35,7 +36,9 @@ actor RecordingCoordinator {
3536
cameraDeviceId: String? = nil,
3637
cameraResolution: String = "1080p",
3738
existingWebcam: (WebcamCapture, VerifiedCamera)? = nil,
38-
cursorMetadataRecorder: CursorMetadataRecorder? = nil
39+
cursorMetadataRecorder: CursorMetadataRecorder? = nil,
40+
captureQuality: CaptureQuality = .standard,
41+
retinaCapture: Bool = false
3942
) async throws -> Date {
4043
var verifiedCam: (capture: WebcamCapture, info: VerifiedCamera)?
4144
var verifiedMic: MicrophoneCapture?
@@ -97,7 +100,12 @@ actor RecordingCoordinator {
97100

98101
pixelW = Int(round(sourceRect.width * displayScale)) & ~1
99102
pixelH = Int(round(sourceRect.height * displayScale)) & ~1
103+
if retinaCapture {
104+
pixelW = (pixelW * 2) & ~1
105+
pixelH = (pixelH * 2) & ~1
106+
}
100107
recordingFPS = fps
108+
captureQualityUsed = captureQuality
101109

102110
var streamCount = 1
103111
if verifiedMic != nil { streamCount += 1 }
@@ -108,11 +116,12 @@ actor RecordingCoordinator {
108116
self.recordingClock = clock
109117

110118
let vidWriter = try VideoTrackWriter(
111-
outputURL: FileManager.default.tempVideoURL(),
119+
outputURL: FileManager.default.tempVideoURL(captureQuality: captureQuality),
112120
width: pixelW,
113121
height: pixelH,
114122
fps: fps,
115-
clock: clock
123+
clock: clock,
124+
captureQuality: captureQuality
116125
)
117126
self.videoWriter = vidWriter
118127

@@ -136,14 +145,15 @@ actor RecordingCoordinator {
136145
self.cursorMetadataRecorder = cursorMetadataRecorder
137146
}
138147

139-
let session = ScreenCaptureSession(videoWriter: vidWriter)
148+
let session = ScreenCaptureSession(videoWriter: vidWriter, captureQuality: captureQuality)
140149
do {
141150
try await session.start(
142151
target: target,
143152
display: display,
144153
displayScale: displayScale,
145154
fps: fps,
146-
hideCursor: cursorMetadataRecorder != nil
155+
hideCursor: cursorMetadataRecorder != nil,
156+
retinaCapture: retinaCapture
147157
)
148158
} catch {
149159
verifiedCam?.capture.stop()
@@ -217,7 +227,9 @@ actor RecordingCoordinator {
217227
microphoneDeviceId: String? = nil,
218228
cameraDeviceId: String? = nil,
219229
cameraResolution: String = "1080p",
220-
existingWebcam: (WebcamCapture, VerifiedCamera)? = nil
230+
existingWebcam: (WebcamCapture, VerifiedCamera)? = nil,
231+
captureQuality: CaptureQuality = .standard,
232+
retinaCapture: Bool = false
221233
) async throws -> Date {
222234
var verifiedCam: (capture: WebcamCapture, info: VerifiedCamera)?
223235
var verifiedMic: MicrophoneCapture?
@@ -271,10 +283,15 @@ actor RecordingCoordinator {
271283
pW = 1920
272284
pH = 1080
273285
}
286+
if retinaCapture {
287+
pW = (pW * 2) & ~1
288+
pH = (pH * 2) & ~1
289+
}
274290

275291
pixelW = pW
276292
pixelH = pH
277293
recordingFPS = fps
294+
captureQualityUsed = captureQuality
278295

279296
var streamCount = 1
280297
if verifiedMic != nil { streamCount += 1 }
@@ -286,11 +303,12 @@ actor RecordingCoordinator {
286303
self.recordingClock = clock
287304

288305
let vidWriter = try VideoTrackWriter(
289-
outputURL: FileManager.default.tempVideoURL(),
306+
outputURL: FileManager.default.tempVideoURL(captureQuality: captureQuality),
290307
width: pW,
291308
height: pH,
292309
fps: fps,
293-
clock: clock
310+
clock: clock,
311+
captureQuality: captureQuality
294312
)
295313
self.videoWriter = vidWriter
296314
deviceCapture.attachVideoWriter(vidWriter)
@@ -473,7 +491,8 @@ actor RecordingCoordinator {
473491
cursorMetadataURL: cursorMetadataURL,
474492
screenSize: CGSize(width: screenW, height: screenH),
475493
webcamSize: webcamURL != nil ? CGSize(width: camW, height: camH) : nil,
476-
fps: fps
494+
fps: fps,
495+
captureQuality: captureQualityUsed
477496
)
478497
}
479498

0 commit comments

Comments
 (0)