feat: 媒体上传

This commit is contained in:
jinyaqiu 2025-08-20 16:20:29 +08:00
parent 1fca9f413c
commit 1e6305ec35
6 changed files with 636 additions and 1 deletions

View File

@ -0,0 +1,84 @@
import AVFoundation
import UIKit
import os.log
///
enum MediaUtils {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaUtils")
/// URL
/// - Parameters:
/// - videoURL: URL
/// - completion: UIImage
static func extractFirstFrame(from videoURL: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
let asset = AVURLAsset(url: videoURL)
let assetImgGenerate = AVAssetImageGenerator(asset: asset)
assetImgGenerate.appliesPreferredTrackTransform = true
//
let duration = asset.duration
let durationTime = CMTimeGetSeconds(duration)
// 0
guard durationTime > 0 else {
let error = NSError(domain: "com.yourapp.media", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid video duration"])
completion(.failure(error))
return
}
// 0
let time = CMTime(seconds: 0, preferredTimescale: 600)
//
assetImgGenerate.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { (_, cgImage, _, result, error) in
if let error = error {
logger.error("Failed to generate image: \(error.localizedDescription)")
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
guard result == .succeeded, let cgImage = cgImage else {
let error = NSError(domain: "com.yourapp.media", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to generate image from video"])
logger.error("Failed to generate image: \(error.localizedDescription)")
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
// UIImage
let image = UIImage(cgImage: cgImage)
DispatchQueue.main.async {
completion(.success(image))
}
}
}
///
/// - Parameters:
/// - videoData:
/// - completion: UIImage
static func extractFirstFrame(from videoData: Data, completion: @escaping (Result<UIImage, Error>) -> Void) {
// URL
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileName = "tempVideo_\(UUID().uuidString).mov"
let fileURL = tempDirectoryURL.appendingPathComponent(fileName)
do {
//
try videoData.write(to: fileURL)
// URL
extractFirstFrame(from: fileURL) { result in
//
try? FileManager.default.removeItem(at: fileURL)
completion(result)
}
} catch {
logger.error("Failed to write video data to temporary file: \(error.localizedDescription)")
completion(.failure(error))
}
}
}

View File

@ -0,0 +1,221 @@
import SwiftUI
import PhotosUI
import AVFoundation
import os.log
///
enum MediaType: Equatable {
case image(UIImage)
case video(URL, UIImage?) // URL UIImage
var thumbnail: UIImage? {
switch self {
case .image(let image):
return image
case .video(_, let thumbnail):
return thumbnail
}
}
var isVideo: Bool {
if case .video = self {
return true
}
return false
}
static func == (lhs: MediaType, rhs: MediaType) -> Bool {
switch (lhs, rhs) {
case (.image(let lhsImage), .image(let rhsImage)):
return lhsImage.pngData() == rhsImage.pngData()
case (.video(let lhsURL, _), .video(let rhsURL, _)):
return lhsURL == rhsURL
default:
return false
}
}
}
struct MediaPicker: UIViewControllerRepresentable {
@Binding var selectedMedia: [MediaType]
var selectionLimit: Int
var onDismiss: (() -> Void)?
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
//
configuration.filter = .any(of: [.videos, .images])
configuration.selectionLimit = selectionLimit
configuration.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: MediaPicker
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaPicker")
private var processedCount = 0
private var totalToProcess = 0
private var tempMedia: [MediaType] = []
init(_ parent: MediaPicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !results.isEmpty else {
parent.onDismiss?()
return
}
processedCount = 0
totalToProcess = results.count
tempMedia = []
for result in results {
let itemProvider = result.itemProvider
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
processImage(itemProvider: itemProvider) { [weak self] media in
self?.handleProcessedMedia(media)
}
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
processVideo(itemProvider: itemProvider) { [weak self] media in
self?.handleProcessedMedia(media)
}
} else {
processedCount += 1
checkCompletion()
}
}
}
private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
if let image = object as? UIImage {
completion(.image(image))
} else {
self.logger.error("Failed to load image: \(error?.localizedDescription ?? "Unknown error")")
completion(nil)
}
}
}
private func processVideo(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
guard let videoURL = url, error == nil else {
self.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")")
completion(nil)
return
}
// URL
let tempDirectory = FileManager.default.temporaryDirectory
let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
do {
//
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: videoURL, to: targetURL)
//
MediaUtils.extractFirstFrame(from: targetURL) { result in
switch result {
case .success(let thumbnail):
completion(.video(targetURL, thumbnail))
case .failure(let error):
self.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)")
// 使
completion(.video(targetURL, nil))
}
}
} catch {
self.logger.error("Failed to copy video file: \(error.localizedDescription)")
completion(nil)
}
}
}
private func handleProcessedMedia(_ media: MediaType?) {
if let media = media {
DispatchQueue.main.async { [weak self] in
self?.tempMedia.append(media)
self?.checkCompletion()
}
} else {
processedCount += 1
checkCompletion()
}
}
private func checkCompletion() {
processedCount += 1
if processedCount >= totalToProcess {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if !self.tempMedia.isEmpty {
self.parent.selectedMedia = self.tempMedia
}
self.parent.onDismiss?()
}
}
}
}
}
// MARK: -
struct MediaThumbnailView: View {
let media: MediaType
let onDelete: (() -> Void)?
var body: some View {
ZStack(alignment: .topTrailing) {
//
if let thumbnail = media.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
} else {
Color.gray
.frame(width: 80, height: 80)
.cornerRadius(8)
}
//
if media.isVideo {
Image(systemName: "video.fill")
.foregroundColor(.white)
.padding(4)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
.padding(4)
}
//
if let onDelete = onDelete {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Color.white)
.clipShape(Circle())
}
.offset(x: 8, y: -8)
}
}
}
}

View File

@ -0,0 +1,138 @@
import SwiftUI
import os.log
class MediaUploader: ObservableObject {
@Published var selectedMedia: [MediaType] = []
@Published private(set) var isUploading = false
@Published private(set) var uploadProgress: [Int: Double] = [:] //
@Published var showError = false
@Published private(set) var errorMessage = ""
@Published var showMediaPicker = false
let maxSelection: Int
var onUploadComplete: ([(MediaType, URL)]?) -> Void
var uploadFunction: (MediaType, @escaping (Double) -> Void) async throws -> URL
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaUploader")
init(maxSelection: Int,
onUploadComplete: @escaping ([(MediaType, URL)]?) -> Void,
uploadFunction: @escaping (MediaType, @escaping (Double) -> Void) async throws -> URL) {
self.maxSelection = maxSelection
self.onUploadComplete = onUploadComplete
self.uploadFunction = uploadFunction
}
//
func showPicker() {
DispatchQueue.main.async { [weak self] in
self?.showMediaPicker = true
}
}
//
func startUpload() async -> [(MediaType, URL)]? {
guard !selectedMedia.isEmpty else { return nil }
isUploading = true
uploadProgress.removeAll()
do {
var uploadedResults: [(MediaType, URL)] = []
for (index, media) in selectedMedia.enumerated() {
let url = try await uploadFunction(media) { [weak self] progress in
DispatchQueue.main.async {
self?.uploadProgress[index] = progress
}
}
uploadedResults.append((media, url))
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.isUploading = false
self.onUploadComplete(uploadedResults)
self.selectedMedia.removeAll()
}
return uploadedResults
} catch {
logger.error("上传失败: \(error.localizedDescription)")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.errorMessage = "上传失败: \(error.localizedDescription)"
self.showError = true
self.isUploading = false
}
return nil
}
}
}
struct MediaUploaderView: View {
@ObservedObject var mediaUploader: MediaUploader
var body: some View {
VStack(alignment: .leading, spacing: 16) {
//
if !mediaUploader.selectedMedia.isEmpty {
Text("已选择 \(mediaUploader.selectedMedia.count) 个文件")
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
.fullScreenCover(isPresented: $mediaUploader.showMediaPicker) {
MediaPicker(
selectedMedia: $mediaUploader.selectedMedia,
selectionLimit: mediaUploader.maxSelection
) {
mediaUploader.showMediaPicker = false
}
}
.alert("错误", isPresented: $mediaUploader.showError) {
Button("确定", role: .cancel) {}
} message: {
Text(mediaUploader.errorMessage)
}
}
}
// MARK: -
struct MediaUploader_Previews: PreviewProvider {
static var previews: some View {
Group {
MediaUploaderView(
mediaUploader: MediaUploader(
maxSelection: 5,
onUploadComplete: { _ in },
uploadFunction: { _, _ in
//
try await Task.sleep(nanoseconds: 1_000_000_000)
return URL(string: "https://example.com/uploaded")!
}
)
)
.padding()
.previewLayout(.sizeThatFits)
MediaUploaderView(
mediaUploader: MediaUploader(
maxSelection: 5,
onUploadComplete: { _ in },
uploadFunction: { _, _ in
//
try await Task.sleep(nanoseconds: 1_000_000_000)
return URL(string: "https://example.com/uploaded")!
}
)
)
.padding()
.previewLayout(.sizeThatFits)
.preferredColorScheme(.dark)
}
}
}

View File

@ -0,0 +1,88 @@
import SwiftUI
import PhotosUI
import AVFoundation
import os.log
struct VideoPicker: UIViewControllerRepresentable {
@Binding var selectedVideoURL: URL?
@Binding var thumbnailImage: UIImage?
var onDismiss: (() -> Void)?
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.filter = .videos
configuration.selectionLimit = 1
configuration.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: VideoPicker
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "VideoPicker")
init(_ parent: VideoPicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let result = results.first else {
parent.onDismiss?()
return
}
// URL
result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, error in
guard let self = self, let videoURL = url, error == nil else {
self?.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")")
DispatchQueue.main.async {
self?.parent.onDismiss?()
}
return
}
// URL
let tempDirectory = FileManager.default.temporaryDirectory
let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
do {
//
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: videoURL, to: targetURL)
//
MediaUtils.extractFirstFrame(from: targetURL) { [weak self] result in
switch result {
case .success(let image):
DispatchQueue.main.async {
self?.parent.thumbnailImage = image
self?.parent.selectedVideoURL = targetURL
self?.parent.onDismiss?()
}
case .failure(let error):
self?.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)")
DispatchQueue.main.async {
self?.parent.onDismiss?()
}
}
}
} catch {
self.logger.error("Failed to copy video file: \(error.localizedDescription)")
DispatchQueue.main.async {
self.parent.onDismiss?()
}
}
}
}
}
}

View File

@ -0,0 +1,104 @@
import SwiftUI
import os.log
public struct ExampleView: View {
@StateObject private var mediaUploader = MediaUploader(
maxSelection: 5,
onUploadComplete: { _ in },
uploadFunction: { media, progress in
//
for i in 0...10 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1
progress(Double(i) / 10.0)
}
//
switch media {
case .image(let image):
//
guard let url = URL(string: "https://example.com/images/\(UUID().uuidString).jpg") else {
throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
}
return url
case .video(let url, _):
//
guard let url = URL(string: "https://example.com/videos/\(UUID().uuidString).mp4") else {
throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
}
return url
}
}
)
@State private var uploadedURLs: [URL] = []
@EnvironmentObject private var authState: AuthState
public init() {}
public var body: some View {
NavigationView {
VStack {
//
Button(action: {
mediaUploader.showPicker()
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("添加媒体")
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding(.horizontal)
//
MediaUploaderView(mediaUploader: mediaUploader)
.onChange(of: mediaUploader.selectedMedia) { newValue in
if !newValue.isEmpty {
Task {
if let results = await mediaUploader.startUpload() {
let urls = results.map { $0.1 }
uploadedURLs.append(contentsOf: urls)
}
}
}
}
// URL
List(uploadedURLs, id: \.self) { url in
Text(url.absoluteString)
.font(.caption)
.padding(.vertical, 4)
}
.listStyle(PlainListStyle())
}
.navigationTitle("媒体上传示例")
}
}
//
private func uploadMedia(_ media: MediaType, progress: @escaping (Double) -> Void) async throws -> URL {
//
for i in 0...10 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1
progress(Double(i) / 10.0)
}
//
switch media {
case .image(let image):
//
return URL(string: "https://example.com/images/\(UUID().uuidString).jpg")!
case .video(let url, _):
//
return URL(string: "https://example.com/videos/\(UUID().uuidString).mp4")!
}
}
}
#Preview {
ExampleView()
.environmentObject(AuthState.shared)
}

View File

@ -46,7 +46,7 @@ struct WakeApp: App {
// userInfo
// UserInfo()
// .environmentObject(authState)
MultiImageUploadExampleView()
ExampleView()
.environmentObject(authState)
} else {
//