Skip to content

Commit a8adc2e

Browse files
committed
feat: export resolution and quality options
Export was locked to source resolution at a fixed 0.1 bits-per-pixel. A new options popover next to Export offers resolution (full/75%/50%) and quality (low/medium/high bpp). The compositor renders straight into the scaled destination buffer, so downscaling rides the existing GPU video-quad sampling, and overlays — positioned in normalized space — scale with the frame for free.
1 parent 1a45962 commit a8adc2e

2 files changed

Lines changed: 63 additions & 6 deletions

File tree

CineScreen/Editor/Sidebar/SidebarView.swift

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ struct SidebarView: View {
1010
/// Live pipeline while an export runs — kept so the Cancel button can
1111
/// reach it. Nil outside an export.
1212
@State private var exportPipeline: ExportPipeline?
13+
@State private var showExportOptions = false
14+
/// Output size as a fraction of the source (1.0 = native).
15+
@State private var exportScale: Double = 1.0
16+
/// H.264 bits-per-pixel budget; 0.10 matches the previous fixed value.
17+
@State private var exportBitsPerPixel: Double = 0.10
1318

1419
var body: some View {
1520
VStack(alignment: .leading, spacing: 14) {
@@ -88,6 +93,44 @@ struct SidebarView: View {
8893

8994
Spacer()
9095

96+
Button {
97+
showExportOptions = true
98+
} label: {
99+
Image(systemName: "slider.horizontal.3")
100+
.font(.system(size: 12, weight: .medium))
101+
.frame(width: 32, height: 32)
102+
.foregroundStyle(CTheme.textSecondary)
103+
.background(
104+
RoundedRectangle(cornerRadius: CTheme.Radius.sm, style: .continuous)
105+
.fill(CTheme.surface)
106+
)
107+
.overlay(
108+
RoundedRectangle(cornerRadius: CTheme.Radius.sm, style: .continuous)
109+
.strokeBorder(CTheme.stroke, lineWidth: 1)
110+
)
111+
}
112+
.buttonStyle(.plain)
113+
.disabled(isExporting)
114+
.help("Export options")
115+
.popover(isPresented: $showExportOptions, arrowEdge: .bottom) {
116+
VStack(alignment: .leading, spacing: 12) {
117+
Picker("Resolution", selection: $exportScale) {
118+
Text("Full").tag(1.0)
119+
Text("75%").tag(0.75)
120+
Text("50%").tag(0.5)
121+
}
122+
.pickerStyle(.segmented)
123+
Picker("Quality", selection: $exportBitsPerPixel) {
124+
Text("Low").tag(0.05)
125+
Text("Medium").tag(0.10)
126+
Text("High").tag(0.16)
127+
}
128+
.pickerStyle(.segmented)
129+
}
130+
.padding(14)
131+
.frame(width: 240)
132+
}
133+
91134
Button {
92135
startExport()
93136
} label: {
@@ -744,7 +787,9 @@ struct SidebarView: View {
744787
canvas: vm.canvasStyle,
745788
webcamURL: vm.webcamURL,
746789
webcamLayout: webcamLayout,
747-
webcamOffsetMs: vm.metadata?.webcamOffsetMs ?? 0
790+
webcamOffsetMs: vm.metadata?.webcamOffsetMs ?? 0,
791+
outputScale: exportScale,
792+
bitsPerPixel: exportBitsPerPixel
748793
)) { progress in
749794
progressCont.yield(progress)
750795
}

CineScreen/Export/ExportPipeline.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ final class ExportPipeline {
6666
/// Camera warm-up offset (screenT = webcamT + offsetMs) — shifts the
6767
/// webcam read window so the overlay is time-aligned with the screen.
6868
var webcamOffsetMs: Double = 0
69+
/// Output size as a fraction of the source (1.0 = native). Overlays
70+
/// are positioned in normalized space, so they scale with the frame.
71+
var outputScale: Double = 1.0
72+
/// H.264 bits-per-pixel budget (bitrate = w*h*fps*bpp).
73+
var bitsPerPixel: Double = 0.10
6974
}
7075

7176
enum Progress {
@@ -167,11 +172,18 @@ final class ExportPipeline {
167172
.appendingPathComponent(".cinescreen-export-\(UUID().uuidString).mp4")
168173
let writer = try AVAssetWriter(outputURL: tempURL, fileType: .mp4)
169174

170-
let videoBitrate = Int(Double(Int(outputSize.width) * Int(outputSize.height)) * Double(fps) * 0.10)
175+
// Scaled output dimensions (even, for the encoder). The compositor
176+
// renders into the destination buffer, so downscaling happens on the
177+
// GPU as part of the normal video-quad sampling.
178+
let scale = min(1.0, max(0.1, input.outputScale))
179+
let scaledW = max(2, (Int(outputSize.width * scale) / 2) * 2)
180+
let scaledH = max(2, (Int(outputSize.height * scale) / 2) * 2)
181+
182+
let videoBitrate = Int(Double(scaledW * scaledH) * Double(fps) * input.bitsPerPixel)
171183
let videoSettings: [String: Any] = [
172184
AVVideoCodecKey: AVVideoCodecType.h264,
173-
AVVideoWidthKey: Int(outputSize.width),
174-
AVVideoHeightKey: Int(outputSize.height),
185+
AVVideoWidthKey: scaledW,
186+
AVVideoHeightKey: scaledH,
175187
AVVideoCompressionPropertiesKey: [
176188
AVVideoAverageBitRateKey: videoBitrate,
177189
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
@@ -186,8 +198,8 @@ final class ExportPipeline {
186198

187199
let pixelBufferAttrs: [String: Any] = [
188200
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
189-
kCVPixelBufferWidthKey as String: Int(outputSize.width),
190-
kCVPixelBufferHeightKey as String: Int(outputSize.height),
201+
kCVPixelBufferWidthKey as String: scaledW,
202+
kCVPixelBufferHeightKey as String: scaledH,
191203
kCVPixelBufferMetalCompatibilityKey as String: true,
192204
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
193205
]

0 commit comments

Comments
 (0)