Skip to content

Commit 5ac5ac5

Browse files
committed
Tighten video compression budgets and temp-space checks
Lower the Writer video compression size targets to 7 Mbps for 1080p, 4 Mbps for 720p, and 2 Mbps for 480p, and log the resulting bitrate/file-length budget used for each export. Fix the preflight storage gate to account for multipass export scratch space, filter compression options that do not fit current temporary disk capacity, and report the required temp-space budget in the disk-space alert. Add Technotes/VideoCompression.md to document the current presets, sizing rules, bitrate assumptions, and export pipeline.
1 parent 0aed4e1 commit 5ac5ac5

4 files changed

Lines changed: 356 additions & 33 deletions

File tree

Planet/Writer/VideoCompressionJob.swift

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ private func videoCompressionFormatString(_ value: String?) -> String {
5151
value ?? "nil"
5252
}
5353

54+
private func videoCompressionFormatBitrate(_ value: Double?) -> String {
55+
guard let value, value > 0 else {
56+
return "nil"
57+
}
58+
if value >= 1_000_000 {
59+
return String(format: "%.3fMbps", value / 1_000_000)
60+
}
61+
if value >= 1_000 {
62+
return String(format: "%.1fKbps", value / 1_000)
63+
}
64+
return String(format: "%.0fbps", value)
65+
}
66+
67+
private func videoCompressionFormatBitrate(_ value: Int64?) -> String {
68+
guard let value else {
69+
return "nil"
70+
}
71+
return videoCompressionFormatBitrate(Double(value))
72+
}
73+
5474
private func videoCompressionDescribeError(_ error: Error) -> String {
5575
let nsError = error as NSError
5676
return "domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription)"
@@ -136,6 +156,20 @@ struct VideoCompressionJob {
136156
usesHEVC ? AVAssetExportPresetHEVCHighestQuality : AVAssetExportPresetHighestQuality
137157
}
138158

159+
fileprivate var targetVideoBitrate: Int64 {
160+
switch self {
161+
case .h264FitInside1080p, .h265FitInside1080p:
162+
return 7_000_000
163+
case .h264FitInside720p, .h265FitInside720p:
164+
return 4_000_000
165+
case .h264FitInside480p, .h265FitInside480p:
166+
return 2_000_000
167+
}
168+
}
169+
170+
private static let containerOverheadMultiplier = 1.03
171+
private static let conservativeAudioBitrateBudget: Int64 = 192_000
172+
139173
fileprivate var landscapeBoundingSize: CGSize {
140174
switch self {
141175
case .h264FitInside1080p, .h265FitInside1080p:
@@ -181,11 +215,73 @@ struct VideoCompressionJob {
181215
return max(2, roundedDown)
182216
}
183217

218+
fileprivate func audioBitrateBudget(sourceAudioBitrate: Double?) -> Int64 {
219+
let fallbackAudioBitrate: Int64 = 128_000
220+
guard let sourceAudioBitrate, sourceAudioBitrate > 0 else {
221+
return fallbackAudioBitrate
222+
}
223+
224+
let rounded = Int64(sourceAudioBitrate.rounded())
225+
return min(max(rounded, 96_000), 192_000)
226+
}
227+
228+
fileprivate func fileLengthLimit(
229+
duration: CMTime,
230+
sourceAudioBitrate: Double?
231+
) -> Int64? {
232+
let durationSeconds = CMTimeGetSeconds(duration)
233+
guard durationSeconds.isFinite, durationSeconds > 0 else {
234+
return nil
235+
}
236+
237+
let totalBitrate = targetVideoBitrate + audioBitrateBudget(
238+
sourceAudioBitrate: sourceAudioBitrate
239+
)
240+
return Int64(
241+
ceil(durationSeconds * Double(totalBitrate) / 8 * Self.containerOverheadMultiplier)
242+
)
243+
}
244+
245+
func estimatedMultipassTemporaryCapacityBytes(
246+
durationSeconds: Double?,
247+
sourceFileSizeBytes: Int64?
248+
) -> Int64? {
249+
let outputBudgetBytes = estimatedOutputBudgetBytes(durationSeconds: durationSeconds)
250+
let exportUnitBytes = [sourceFileSizeBytes, outputBudgetBytes]
251+
.compactMap { $0 }
252+
.max()
253+
254+
guard let exportUnitBytes, exportUnitBytes > 0 else {
255+
return nil
256+
}
257+
258+
let doubled = exportUnitBytes.multipliedReportingOverflow(by: 2)
259+
return doubled.overflow ? Int64.max : doubled.partialValue
260+
}
261+
262+
private func estimatedOutputBudgetBytes(durationSeconds: Double?) -> Int64? {
263+
guard let durationSeconds, durationSeconds.isFinite, durationSeconds > 0 else {
264+
return nil
265+
}
266+
267+
let totalBitrate = targetVideoBitrate + audioBitrateBudget(
268+
sourceAudioBitrate: Double(Self.conservativeAudioBitrateBudget)
269+
)
270+
return Int64(
271+
ceil(durationSeconds * Double(totalBitrate) / 8 * Self.containerOverheadMultiplier)
272+
)
273+
}
274+
184275
var debugDescription: String {
185-
"id=\(id) title=\(title) preset=\(exportPresetName) usesHEVC=\(usesHEVC) boundingSize=\(videoCompressionFormatSize(landscapeBoundingSize))"
276+
"id=\(id) title=\(title) preset=\(exportPresetName) usesHEVC=\(usesHEVC) boundingSize=\(videoCompressionFormatSize(landscapeBoundingSize)) targetVideoBitrate=\(videoCompressionFormatBitrate(targetVideoBitrate))"
186277
}
187278
}
188279

280+
private struct PreparedVideoComposition {
281+
let composition: AVMutableVideoComposition
282+
let duration: CMTime
283+
}
284+
189285
struct PreparedExport {
190286
let session: AVAssetExportSession
191287
let outputURL: URL
@@ -244,10 +340,31 @@ struct VideoCompressionJob {
244340
session.outputURL = outputURL
245341
session.outputFileType = outputFileType
246342
session.shouldOptimizeForNetworkUse = true
247-
session.videoComposition = try await makeVideoComposition(for: asset)
343+
session.canPerformMultiplePassesOverSourceMediaData = true
344+
session.directoryForTemporaryFiles = FileManager.default.temporaryDirectory
345+
346+
let preparedVideoComposition = try await makeVideoComposition(for: asset)
347+
session.videoComposition = preparedVideoComposition.composition
348+
349+
let sourceAudioBitrate = await estimatedAudioBitrate(for: asset)
350+
let targetVideoBitrate = option.targetVideoBitrate
351+
let audioBitrateBudget = option.audioBitrateBudget(
352+
sourceAudioBitrate: sourceAudioBitrate
353+
)
354+
let fileLengthLimit = option.fileLengthLimit(
355+
duration: preparedVideoComposition.duration,
356+
sourceAudioBitrate: sourceAudioBitrate
357+
)
358+
if let fileLengthLimit {
359+
session.fileLengthLimit = fileLengthLimit
360+
}
248361

249362
VideoLogger.log(
250-
"[VideoCompressionJob] prepareExport ready outputURL=\(outputURL.path) outputFileType=\(outputFileType.rawValue) renderSize=\(videoCompressionFormatSize(session.videoComposition?.renderSize ?? .zero)) frameDurationSeconds=\(videoCompressionFormatSeconds(session.videoComposition?.frameDuration ?? .invalid)) optimizeForNetworkUse=\(session.shouldOptimizeForNetworkUse)"
363+
"[VideoCompressionJob] configured bitrate budget targetVideoBitrate=\(videoCompressionFormatBitrate(targetVideoBitrate)) sourceAudioBitrate=\(videoCompressionFormatBitrate(sourceAudioBitrate)) audioBitrateBudget=\(videoCompressionFormatBitrate(audioBitrateBudget)) fileLengthLimitBytes=\(videoCompressionFormatBytes(fileLengthLimit)) multiplePasses=\(session.canPerformMultiplePassesOverSourceMediaData)"
364+
)
365+
366+
VideoLogger.log(
367+
"[VideoCompressionJob] prepareExport ready outputURL=\(outputURL.path) outputFileType=\(outputFileType.rawValue) renderSize=\(videoCompressionFormatSize(session.videoComposition?.renderSize ?? .zero)) frameDurationSeconds=\(videoCompressionFormatSeconds(session.videoComposition?.frameDuration ?? .invalid)) optimizeForNetworkUse=\(session.shouldOptimizeForNetworkUse) fileLengthLimitBytes=\(videoCompressionFormatBytes(fileLengthLimit))"
251368
)
252369

253370
return PreparedExport(session: session, outputURL: outputURL)
@@ -298,7 +415,7 @@ struct VideoCompressionJob {
298415
}
299416
}
300417

301-
private func makeVideoComposition(for asset: AVAsset) async throws -> AVMutableVideoComposition {
418+
private func makeVideoComposition(for asset: AVAsset) async throws -> PreparedVideoComposition {
302419
guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else {
303420
VideoLogger.log(
304421
"[VideoCompressionJob] asset has no readable video track source=\(sourceURL.path)"
@@ -363,7 +480,10 @@ struct VideoCompressionJob {
363480
VideoLogger.log(
364481
"[VideoCompressionJob] video composition prepared renderSize=\(videoCompressionFormatSize(renderSize)) frameDurationSeconds=\(videoCompressionFormatSeconds(videoComposition.frameDuration)) transform=\(videoCompressionFormatTransform(transform)) instructionDurationSeconds=\(videoCompressionFormatSeconds(duration))"
365482
)
366-
return videoComposition
483+
return PreparedVideoComposition(
484+
composition: videoComposition,
485+
duration: duration
486+
)
367487
}
368488

369489
private func preferredOutputFileType(for session: AVAssetExportSession) throws -> AVFileType {
@@ -426,6 +546,17 @@ struct VideoCompressionJob {
426546
}
427547
}
428548

549+
private func estimatedAudioBitrate(for asset: AVAsset) async -> Double? {
550+
guard let audioTrack = try? await asset.loadTracks(withMediaType: .audio).first,
551+
let estimatedDataRate = try? await audioTrack.load(.estimatedDataRate),
552+
estimatedDataRate > 0
553+
else {
554+
return nil
555+
}
556+
557+
return Double(estimatedDataRate)
558+
}
559+
429560
private func scaledTransform(
430561
naturalSize: CGSize,
431562
preferredTransform: CGAffineTransform,

Planet/Writer/VideoInfoRow.swift

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ private enum CompressionStorageDecision {
1111
case compressWithBackup
1212
}
1313

14+
private struct CompressionStorageAssessment {
15+
let decision: CompressionStorageDecision
16+
let videoSizeBytes: Int64?
17+
let requiredCapacityBytes: Int64?
18+
let requiredCapacityWithBackupBytes: Int64?
19+
let availableCapacityBytes: Int64?
20+
}
21+
1422
struct VideoInfoRow: View {
1523
@ObservedObject var videoAttachment: Attachment
1624
@ObservedObject var viewModel: WriterViewModel
@@ -24,6 +32,7 @@ struct VideoInfoRow: View {
2432
@State private var compressionStartedAt: Date?
2533
@State private var compressionFramesPerSecond: Double?
2634
@State private var lastLoggedCompressionProgressStep: Int = -1
35+
@State private var presentableCompressionOptions: [VideoCompressionJob.Option] = []
2736
@State private var isCompressing: Bool = false
2837
@State private var isShowingCompressionOptions: Bool = false
2938

@@ -161,19 +170,30 @@ struct VideoInfoRow: View {
161170

162171
@MainActor
163172
private func openCompressionOptions() {
164-
let assessment = compressionStorageDecision(for: videoAttachment.path)
165-
logStorageAssessment(assessment, context: "openCompressionOptions")
166-
guard assessment.decision != .blocked else {
173+
let assessments = availableCompressionOptions.map {
174+
($0, compressionStorageAssessment(for: videoAttachment.path, option: $0))
175+
}
176+
let feasibleOptions = assessments.compactMap { option, assessment in
177+
assessment.decision == .blocked ? nil : option
178+
}
179+
presentableCompressionOptions = feasibleOptions
180+
guard !feasibleOptions.isEmpty else {
181+
let blockingAssessment = assessments
182+
.map(\.1)
183+
.min { lhs, rhs in
184+
(lhs.requiredCapacityBytes ?? .max) < (rhs.requiredCapacityBytes ?? .max)
185+
} ?? compressionStorageAssessment(for: videoAttachment.path, option: nil)
186+
logStorageAssessment(blockingAssessment, context: "openCompressionOptions")
167187
log("compression options blocked because temporary disk space is insufficient")
168188
showInsufficientDiskSpaceAlert(
169-
videoSizeBytes: assessment.videoSizeBytes,
170-
availableCapacityBytes: assessment.availableCapacityBytes
189+
requiredCapacityBytes: blockingAssessment.requiredCapacityBytes,
190+
availableCapacityBytes: blockingAssessment.availableCapacityBytes
171191
)
172192
return
173193
}
174194

175195
log(
176-
"showing compression options availableOptions=\(availableCompressionOptions.map(\.id).joined(separator: ",")) videoInfoLoaded=\(videoInfo != nil)"
196+
"showing compression options availableOptions=\(feasibleOptions.map(\.id).joined(separator: ",")) videoInfoLoaded=\(videoInfo != nil)"
177197
)
178198
isShowingCompressionOptions = true
179199
}
@@ -185,13 +205,13 @@ struct VideoInfoRow: View {
185205
return
186206
}
187207

188-
let assessment = compressionStorageDecision(for: videoAttachment.path)
208+
let assessment = compressionStorageAssessment(for: videoAttachment.path, option: option)
189209
logStorageAssessment(assessment, context: "startCompression")
190210
guard assessment.decision != .blocked else {
191211
isShowingCompressionOptions = false
192212
log("compression blocked before starting export")
193213
showInsufficientDiskSpaceAlert(
194-
videoSizeBytes: assessment.videoSizeBytes,
214+
requiredCapacityBytes: assessment.requiredCapacityBytes,
195215
availableCapacityBytes: assessment.availableCapacityBytes
196216
)
197217
return
@@ -455,7 +475,7 @@ struct VideoInfoRow: View {
455475
}
456476

457477
VStack(spacing: 10) {
458-
ForEach(availableCompressionOptions) { option in
478+
ForEach(presentableCompressionOptions) { option in
459479
Button {
460480
startCompression(using: option)
461481
} label: {
@@ -479,27 +499,63 @@ struct VideoInfoRow: View {
479499
.frame(width: 360)
480500
}
481501

482-
private func compressionStorageDecision(
483-
for sourceURL: URL
484-
) -> (decision: CompressionStorageDecision, videoSizeBytes: Int64?, availableCapacityBytes: Int64?) {
485-
guard let videoSizeBytes = fileSizeBytes(for: sourceURL), videoSizeBytes > 0 else {
486-
return (.compressWithoutBackup, nil, temporaryDirectoryAvailableCapacityBytes())
502+
private func compressionStorageAssessment(
503+
for sourceURL: URL,
504+
option: VideoCompressionJob.Option?
505+
) -> CompressionStorageAssessment {
506+
let videoSizeBytes = fileSizeBytes(for: sourceURL)
507+
let requiredCapacityBytes =
508+
option?.estimatedMultipassTemporaryCapacityBytes(
509+
durationSeconds: videoInfo?.durationSecondsValue,
510+
sourceFileSizeBytes: videoSizeBytes
511+
)
512+
?? videoSizeBytes?.multipliedReportingOverflow(by: 2).partialValue
513+
let requiredCapacityWithBackupBytes: Int64?
514+
if let requiredCapacityBytes, let videoSizeBytes {
515+
let sum = requiredCapacityBytes.addingReportingOverflow(videoSizeBytes)
516+
requiredCapacityWithBackupBytes = sum.overflow ? Int64.max : sum.partialValue
517+
} else {
518+
requiredCapacityWithBackupBytes = nil
487519
}
488520

489521
guard let availableCapacityBytes = temporaryDirectoryAvailableCapacityBytes() else {
490-
return (.compressWithoutBackup, videoSizeBytes, nil)
522+
return CompressionStorageAssessment(
523+
decision: .compressWithoutBackup,
524+
videoSizeBytes: videoSizeBytes,
525+
requiredCapacityBytes: requiredCapacityBytes,
526+
requiredCapacityWithBackupBytes: requiredCapacityWithBackupBytes,
527+
availableCapacityBytes: nil
528+
)
491529
}
492530

493-
if availableCapacityBytes < videoSizeBytes {
494-
return (.blocked, videoSizeBytes, availableCapacityBytes)
531+
if let requiredCapacityBytes, availableCapacityBytes < requiredCapacityBytes {
532+
return CompressionStorageAssessment(
533+
decision: .blocked,
534+
videoSizeBytes: videoSizeBytes,
535+
requiredCapacityBytes: requiredCapacityBytes,
536+
requiredCapacityWithBackupBytes: requiredCapacityWithBackupBytes,
537+
availableCapacityBytes: availableCapacityBytes
538+
)
495539
}
496540

497-
let tenTimesVideoSize = videoSizeBytes.multipliedReportingOverflow(by: 10)
498-
if !tenTimesVideoSize.overflow, availableCapacityBytes >= tenTimesVideoSize.partialValue {
499-
return (.compressWithBackup, videoSizeBytes, availableCapacityBytes)
541+
if let requiredCapacityWithBackupBytes,
542+
availableCapacityBytes >= requiredCapacityWithBackupBytes {
543+
return CompressionStorageAssessment(
544+
decision: .compressWithBackup,
545+
videoSizeBytes: videoSizeBytes,
546+
requiredCapacityBytes: requiredCapacityBytes,
547+
requiredCapacityWithBackupBytes: requiredCapacityWithBackupBytes,
548+
availableCapacityBytes: availableCapacityBytes
549+
)
500550
}
501551

502-
return (.compressWithoutBackup, videoSizeBytes, availableCapacityBytes)
552+
return CompressionStorageAssessment(
553+
decision: .compressWithoutBackup,
554+
videoSizeBytes: videoSizeBytes,
555+
requiredCapacityBytes: requiredCapacityBytes,
556+
requiredCapacityWithBackupBytes: requiredCapacityWithBackupBytes,
557+
availableCapacityBytes: availableCapacityBytes
558+
)
503559
}
504560

505561
private func fileSizeBytes(for url: URL) -> Int64? {
@@ -567,14 +623,14 @@ struct VideoInfoRow: View {
567623
}
568624

569625
private func showInsufficientDiskSpaceAlert(
570-
videoSizeBytes: Int64?,
626+
requiredCapacityBytes: Int64?,
571627
availableCapacityBytes: Int64?
572628
) {
573629
let message: String
574-
if let videoSizeBytes, let availableCapacityBytes {
575-
message = "Planet needs at least \(formattedByteCount(videoSizeBytes)) of free temporary disk space to compress this video. Only \(formattedByteCount(availableCapacityBytes)) is currently available."
630+
if let requiredCapacityBytes, let availableCapacityBytes {
631+
message = "Planet needs at least \(formattedByteCount(requiredCapacityBytes)) of free temporary disk space to compress this video. Only \(formattedByteCount(availableCapacityBytes)) is currently available."
576632
} else {
577-
message = "Planet needs at least as much free temporary disk space as the source video size to compress this video."
633+
message = "Planet needs more free temporary disk space to compress this video."
578634
}
579635

580636
PlanetStore.shared.alert(
@@ -674,11 +730,11 @@ struct VideoInfoRow: View {
674730
}
675731

676732
private func logStorageAssessment(
677-
_ assessment: (decision: CompressionStorageDecision, videoSizeBytes: Int64?, availableCapacityBytes: Int64?),
733+
_ assessment: CompressionStorageAssessment,
678734
context: String
679735
) {
680736
log(
681-
"\(context) decision=\(describeStorageDecision(assessment.decision)) videoSizeBytes=\(formatBytes(assessment.videoSizeBytes)) availableCapacityBytes=\(formatBytes(assessment.availableCapacityBytes))"
737+
"\(context) decision=\(describeStorageDecision(assessment.decision)) videoSizeBytes=\(formatBytes(assessment.videoSizeBytes)) requiredCapacityBytes=\(formatBytes(assessment.requiredCapacityBytes)) requiredCapacityWithBackupBytes=\(formatBytes(assessment.requiredCapacityWithBackupBytes)) availableCapacityBytes=\(formatBytes(assessment.availableCapacityBytes))"
682738
)
683739
}
684740

Planet/versioning.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
CURRENT_PROJECT_VERSION = 2749
1+
CURRENT_PROJECT_VERSION = 2750

0 commit comments

Comments
 (0)