feat: add compress progress display. Set video compress config to 720p at 60 fps
This commit is contained in:
parent
0100d8cbab
commit
f7d0186bf3
|
@ -90,6 +90,15 @@
|
|||
"version" : "2.2.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nextlevelsessionexporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/NextLevel/NextLevelSessionExporter.git",
|
||||
"state" : {
|
||||
"revision" : "b6c0cce1aa37fe1547d694f958fac3c3524b74da",
|
||||
"version" : "0.4.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nuke",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
@ -49,6 +49,7 @@ let package = Package(
|
|||
.package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"),
|
||||
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.4.1"),
|
||||
.package(url: "https://github.com/NextLevel/NextLevelSessionExporter.git", from: "0.4.6"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
|
@ -124,6 +125,7 @@ let package = Package(
|
|||
.product(name: "PanModal", package: "PanModal"),
|
||||
.product(name: "Stripes", package: "Stripes"),
|
||||
.product(name: "Kingfisher", package: "Kingfisher"),
|
||||
.product(name: "NextLevelSessionExporter", package: "NextLevelSessionExporter"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
|
|
|
@ -82,6 +82,10 @@ final class SerialStream: NSObject {
|
|||
|
||||
self.progress.completedUnitCount += Int64(writeResult)
|
||||
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)")
|
||||
|
||||
if writeResult == -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ public struct AttachmentView: View {
|
|||
// loaded
|
||||
// uploading… or upload failed
|
||||
// could retry upload when error emit
|
||||
if viewModel.output != nil {
|
||||
if viewModel.output != nil, viewModel.uploadState != .finish {
|
||||
VisualEffectView(effect: blurEffect)
|
||||
VStack {
|
||||
let action: AttachmentViewModel.Action = {
|
||||
|
@ -74,8 +74,18 @@ public struct AttachmentView: View {
|
|||
.padding()
|
||||
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
|
||||
.overlay(
|
||||
CircleProgressView(progress: viewModel.fractionCompleted)
|
||||
.animation(.default, value: viewModel.fractionCompleted)
|
||||
Group {
|
||||
switch viewModel.uploadState {
|
||||
case .compressing:
|
||||
CircleProgressView(progress: viewModel.videoCompressProgress)
|
||||
.animation(.default, value: viewModel.videoCompressProgress)
|
||||
case .uploading:
|
||||
CircleProgressView(progress: viewModel.fractionCompleted)
|
||||
.animation(.default, value: viewModel.fractionCompleted)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
)
|
||||
.clipShape(Circle())
|
||||
.padding()
|
||||
|
@ -84,11 +94,20 @@ public struct AttachmentView: View {
|
|||
let title: String = {
|
||||
switch action {
|
||||
case .remove:
|
||||
let totalSizeInByte = viewModel.outputSizeInByte
|
||||
let uploadSizeInByte = Double(totalSizeInByte) * min(1.0, viewModel.fractionCompleted)
|
||||
let total = viewModel.byteCountFormatter.string(fromByteCount: Int64(totalSizeInByte))
|
||||
let upload = viewModel.byteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte))
|
||||
return "\(upload) / \(total)"
|
||||
switch viewModel.uploadState {
|
||||
case .compressing:
|
||||
return "Comporessing..." // TODO: i18n
|
||||
default:
|
||||
if viewModel.fractionCompleted < 0.9 {
|
||||
let totalSizeInByte = viewModel.outputSizeInByte
|
||||
let uploadSizeInByte = Double(totalSizeInByte) * min(1.0, viewModel.fractionCompleted + 0.1) // 9:1
|
||||
let total = viewModel.byteCountFormatter.string(fromByteCount: Int64(totalSizeInByte))
|
||||
let upload = viewModel.byteCountFormatter.string(fromByteCount: Int64(uploadSizeInByte))
|
||||
return "\(upload) / \(total)"
|
||||
} else {
|
||||
return "Server Processing..." // TODO: i18n
|
||||
}
|
||||
}
|
||||
case .retry:
|
||||
return "Upload Failed" // TODO: i18n
|
||||
}
|
||||
|
@ -97,7 +116,13 @@ public struct AttachmentView: View {
|
|||
switch action {
|
||||
case .remove:
|
||||
if viewModel.progress.fractionCompleted < 1, viewModel.uploadState == .uploading {
|
||||
return viewModel.remainTimeLocalizedString ?? ""
|
||||
if viewModel.progress.fractionCompleted < 0.9 {
|
||||
return viewModel.remainTimeLocalizedString ?? ""
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
} else if viewModel.videoCompressProgress < 1, viewModel.uploadState == .compressing {
|
||||
return viewModel.percentageFormatter.string(from: NSNumber(floatLiteral: viewModel.videoCompressProgress)) ?? ""
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
@ -113,6 +138,9 @@ public struct AttachmentView: View {
|
|||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal)
|
||||
.lineLimit(nil)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 240)
|
||||
}
|
||||
}
|
||||
} // end ZStack
|
||||
|
|
|
@ -5,29 +5,81 @@
|
|||
// Created by MainasuK on 2022/11/11.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
import SessionExporter
|
||||
import MastodonCore
|
||||
|
||||
extension AttachmentViewModel {
|
||||
func comporessVideo(url: URL) async throws -> URL {
|
||||
let task = Task { () -> URL in
|
||||
let urlAsset = AVURLAsset(url: url)
|
||||
guard let exportSession = AVAssetExportSession(asset: urlAsset, presetName: AVAssetExportPresetMediumQuality) else {
|
||||
throw AttachmentError.invalidAttachmentType
|
||||
}
|
||||
let outputURL = try FileManager.default.createTemporaryFileURL(
|
||||
filename: UUID().uuidString,
|
||||
pathExtension: url.pathExtension
|
||||
)
|
||||
exportSession.outputURL = outputURL
|
||||
exportSession.outputFileType = AVFileType.mp4
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
await exportSession.export()
|
||||
return outputURL
|
||||
let urlAsset = AVURLAsset(url: url)
|
||||
let exporter = NextLevelSessionExporter(withAsset: urlAsset)
|
||||
exporter.outputFileType = .mp4
|
||||
|
||||
let outputURL = try FileManager.default.createTemporaryFileURL(
|
||||
filename: UUID().uuidString,
|
||||
pathExtension: url.pathExtension
|
||||
)
|
||||
exporter.outputURL = outputURL
|
||||
|
||||
let compressionDict: [String: Any] = [
|
||||
AVVideoAverageBitRateKey: NSNumber(integerLiteral: 3000000), // 3000k
|
||||
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel as String,
|
||||
AVVideoAverageNonDroppableFrameRateKey: NSNumber(floatLiteral: 30), // 30 FPS
|
||||
]
|
||||
exporter.videoOutputConfiguration = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: NSNumber(integerLiteral: 1280),
|
||||
AVVideoHeightKey: NSNumber(integerLiteral: 720),
|
||||
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill,
|
||||
AVVideoCompressionPropertiesKey: compressionDict
|
||||
]
|
||||
exporter.audioOutputConfiguration = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVEncoderBitRateKey: NSNumber(integerLiteral: 128000), // 128k
|
||||
AVNumberOfChannelsKey: NSNumber(integerLiteral: 2),
|
||||
AVSampleRateKey: NSNumber(value: Float(44100))
|
||||
]
|
||||
|
||||
// needs set to LOW priority to prevent priority inverse issue
|
||||
let task = Task(priority: .utility) {
|
||||
_ = try await exportVideo(by: exporter)
|
||||
}
|
||||
_ = try await task.value
|
||||
|
||||
self.compressVideoTask = task
|
||||
|
||||
return try await task.value
|
||||
return outputURL
|
||||
}
|
||||
|
||||
private func exportVideo(by exporter: NextLevelSessionExporter) async throws -> URL {
|
||||
guard let outputURL = exporter.outputURL else {
|
||||
throw AppError.badRequest
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
exporter.export(progressHandler: { progress in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.videoCompressProgress = Double(progress)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: export progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress)
|
||||
}
|
||||
}, completionHandler: { result in
|
||||
switch result {
|
||||
case .success(let status):
|
||||
switch status {
|
||||
case .completed:
|
||||
print("NextLevelSessionExporter, export completed, \(exporter.outputURL?.description ?? "")")
|
||||
continuation.resume(with: .success(outputURL))
|
||||
default:
|
||||
if Task.isCancelled {
|
||||
exporter.cancelExport()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel export", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
}
|
||||
print("NextLevelSessionExporter, did not complete")
|
||||
}
|
||||
case .failure(let error):
|
||||
continuation.resume(with: .failure(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
} // end func
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ extension Data {
|
|||
extension AttachmentViewModel {
|
||||
public enum UploadState {
|
||||
case none
|
||||
case compressing
|
||||
case ready
|
||||
case uploading
|
||||
case fail
|
||||
|
|
|
@ -11,6 +11,7 @@ import Combine
|
|||
import PhotosUI
|
||||
import Kingfisher
|
||||
import MastodonCore
|
||||
import func QuartzCore.CACurrentMediaTime
|
||||
|
||||
public protocol AttachmentViewModelDelegate: AnyObject {
|
||||
func attachmentViewModel(_ viewModel: AttachmentViewModel, uploadStateValueDidChange state: AttachmentViewModel.UploadState)
|
||||
|
@ -35,6 +36,12 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
formatter.countStyle = .memory
|
||||
return formatter
|
||||
}()
|
||||
|
||||
let percentageFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .percent
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// input
|
||||
public let api: APIService
|
||||
|
@ -43,7 +50,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
@Published var caption = ""
|
||||
@Published var sizeLimit = SizeLimit()
|
||||
|
||||
var compressVideoTask: Task<URL, Error>?
|
||||
// var compressVideoTask: Task<URL, Error>?
|
||||
|
||||
// output
|
||||
@Published public private(set) var output: Output?
|
||||
|
@ -54,11 +61,14 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
@Published public private(set) var uploadState: UploadState = .none
|
||||
@Published public private(set) var uploadResult: UploadResult?
|
||||
@Published var error: Error?
|
||||
|
||||
var uploadTask: Task<(), Never>?
|
||||
|
||||
@Published var videoCompressProgress: Double = 0
|
||||
|
||||
let progress = Progress() // upload progress
|
||||
@Published var fractionCompleted: Double = 0
|
||||
|
||||
var displayLink: CADisplayLink!
|
||||
private var lastTimestamp: TimeInterval?
|
||||
private var lastUploadSizeInByte: Int64 = 0
|
||||
private var averageUploadSpeedInByte: Int64 = 0
|
||||
|
@ -78,11 +88,15 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
super.init()
|
||||
// end init
|
||||
|
||||
self.displayLink = CADisplayLink(
|
||||
target: self,
|
||||
selector: #selector(AttachmentViewModel.step(displayLink:))
|
||||
)
|
||||
displayLink.add(to: .current, forMode: .common)
|
||||
Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS
|
||||
.autoconnect()
|
||||
.share()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.step()
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
progress
|
||||
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
|
||||
|
@ -120,12 +134,14 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
.assign(to: &$thumbnail)
|
||||
|
||||
defer {
|
||||
Task { @MainActor in
|
||||
let uploadTask = Task { @MainActor in
|
||||
do {
|
||||
var output = try await load(input: input)
|
||||
|
||||
switch output {
|
||||
case .video(let fileURL, let mimeType):
|
||||
self.output = output
|
||||
self.update(uploadState: .compressing)
|
||||
let compressedFileURL = try await comporessVideo(url: fileURL)
|
||||
output = .video(compressedFileURL, mimeType: mimeType)
|
||||
try? FileManager.default.removeItem(at: fileURL) // remove old file
|
||||
|
@ -142,12 +158,17 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
|
|||
self.error = error
|
||||
}
|
||||
} // end Task
|
||||
self.uploadTask = uploadTask
|
||||
Task {
|
||||
await uploadTask.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink.invalidate()
|
||||
displayLink.remove(from: .current, forMode: .common)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
uploadTask?.cancel()
|
||||
|
||||
switch output {
|
||||
case .image:
|
||||
|
@ -172,31 +193,34 @@ extension AttachmentViewModel {
|
|||
return formatter
|
||||
}()
|
||||
|
||||
@objc private func step(displayLink: CADisplayLink) {
|
||||
@objc private func step() {
|
||||
|
||||
let uploadProgress = min(progress.fractionCompleted + 0.1, 1) // the progress split into 9:1 blocks (download : waiting)
|
||||
|
||||
guard let lastTimestamp = self.lastTimestamp else {
|
||||
self.lastTimestamp = displayLink.timestamp
|
||||
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
|
||||
self.lastTimestamp = CACurrentMediaTime()
|
||||
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
|
||||
return
|
||||
}
|
||||
|
||||
let duration = displayLink.timestamp - lastTimestamp
|
||||
let duration = CACurrentMediaTime() - lastTimestamp
|
||||
guard duration >= 1.0 else { return } // update every 1 sec
|
||||
|
||||
let old = self.lastUploadSizeInByte
|
||||
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * progress.fractionCompleted)
|
||||
self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress)
|
||||
|
||||
let newSpeed = self.lastUploadSizeInByte - old
|
||||
let lastAverageSpeed = self.averageUploadSpeedInByte
|
||||
let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed))
|
||||
|
||||
let remainSizeInByte = Double(outputSizeInByte) * (1 - progress.fractionCompleted)
|
||||
let remainSizeInByte = Double(outputSizeInByte) * (1 - uploadProgress)
|
||||
|
||||
let speed = Double(newAverageSpeed)
|
||||
if speed != .zero {
|
||||
// estimate by speed
|
||||
let uploadRemainTimeInSecond = remainSizeInByte / speed
|
||||
// estimate by progress 1s for 10%
|
||||
let remainPercentage = 1 - progress.fractionCompleted
|
||||
let remainPercentage = 1 - uploadProgress
|
||||
let estimateRemainTimeByProgress = remainPercentage / 0.1
|
||||
// max estimate
|
||||
var remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond)
|
||||
|
@ -216,7 +240,7 @@ extension AttachmentViewModel {
|
|||
remainTimeLocalizedString = nil
|
||||
}
|
||||
|
||||
self.lastTimestamp = displayLink.timestamp
|
||||
self.lastTimestamp = CACurrentMediaTime()
|
||||
self.averageUploadSpeedInByte = newAverageSpeed
|
||||
}
|
||||
}
|
||||
|
|
|
@ -427,6 +427,8 @@ extension ComposeContentViewModel: AttachmentViewModelDelegate {
|
|||
switch attachmentViewModel.uploadState {
|
||||
case .none:
|
||||
return
|
||||
case .compressing:
|
||||
return
|
||||
case .ready:
|
||||
let count = self.attachmentViewModels.count
|
||||
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload \(i)/\(count) attachment")
|
||||
|
|
Loading…
Reference in New Issue