feat: 视频缩略图

This commit is contained in:
jinyaqiu 2025-08-29 20:44:01 +08:00
parent 008778d9a6
commit 96e58806d4
5 changed files with 258 additions and 201 deletions

View File

@ -199,32 +199,48 @@ public class ImageUploadService {
self.uploader.uploadImage(
compressedThumbnail,
progress: { _ in },
progress: { uploadProgress in
// 10%
let progressInfo = UploadProgress(
current: 90 + Int(uploadProgress * 10),
total: 100,
progress: 0.9 + (uploadProgress * 0.1),
isOriginal: false
)
progress(progressInfo)
},
completion: { thumbnailResult in
switch thumbnailResult {
case .success(let thumbnailUploadResult):
print("✅ 视频缩略图上传完成, fileId: \(thumbnailUploadResult.fileId)")
let result = MediaUploadResult.video(
video: videoResult,
thumbnail: thumbnailUploadResult
// preview_file_id ID
let finalVideoResult = ImageUploaderGetID.UploadResult(
fileUrl: videoResult.fileUrl,
fileName: videoResult.fileName,
fileSize: videoResult.fileSize,
fileId: videoResult.fileId,
previewFileId: thumbnailUploadResult.fileId // 使IDpreview_file_id
)
completion(.success(result))
completion(.success(.video(video: finalVideoResult, thumbnail: thumbnailUploadResult)))
case .failure(let error):
print("❌ 视频缩略图上传失败: \(error.localizedDescription)")
completion(.failure(error))
// 使
completion(.success(.video(video: videoResult, thumbnail: nil)))
}
}
)
} else {
let error = NSError(domain: "ImageUploadService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to compress thumbnail"])
print("❌ 视频缩略图压缩失败")
completion(.failure(error))
//
completion(.success(.video(video: videoResult, thumbnail: nil)))
}
case .failure(let error):
print("❌ 视频缩略图提取失败: \(error.localizedDescription)")
completion(.failure(error))
//
completion(.success(.video(video: videoResult, thumbnail: nil)))
}
}
@ -352,7 +368,7 @@ public class ImageUploadService {
///
public enum MediaUploadResult {
case file(ImageUploaderGetID.UploadResult)
case video(video: ImageUploaderGetID.UploadResult, thumbnail: ImageUploaderGetID.UploadResult)
case video(video: ImageUploaderGetID.UploadResult, thumbnail: ImageUploaderGetID.UploadResult?)
/// IDID
public var fileId: String {
@ -363,6 +379,36 @@ public class ImageUploadService {
return videoResult.fileId
}
}
/// IDID
public var previewFileId: String? {
switch self {
case .file:
return nil
case .video(_, let thumbnailResult):
return thumbnailResult?.fileId
}
}
/// URLURL
public var fileUrl: String {
switch self {
case .file(let result):
return result.fileUrl
case .video(let videoResult, _):
return videoResult.fileUrl
}
}
/// URL
public var thumbnailUrl: String? {
switch self {
case .file:
return nil
case .video(_, let thumbnailResult):
return thumbnailResult?.fileUrl
}
}
}
///

View File

@ -12,12 +12,14 @@ public class ImageUploaderGetID: ObservableObject {
public let fileName: String
public let fileSize: Int
public let fileId: String
public let previewFileId: String?
public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String) {
public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String, previewFileId: String? = nil) {
self.fileUrl = fileUrl
self.fileName = fileName
self.fileSize = fileSize
self.fileId = fileId
self.previewFileId = previewFileId
}
}
@ -90,7 +92,11 @@ public class ImageUploaderGetID: ObservableObject {
}
// 2. URL
getUploadURL(for: imageData) { [weak self] result in
getUploadURL(
for: imageData,
mimeType: "image/jpeg",
originalFilename: "image_\(UUID().uuidString).jpg"
) { [weak self] result in
switch result {
case .success((let fileId, let uploadURL)):
print("📤 获取到上传URL开始上传文件...")
@ -110,7 +116,7 @@ public class ImageUploaderGetID: ObservableObject {
// 4.
self?.confirmUpload(
fileId: fileId,
fileName: "avatar_\(UUID().uuidString).jpg",
fileName: "image_\(UUID().uuidString).jpg",
fileSize: imageData.count,
completion: completion
)
@ -206,76 +212,6 @@ public class ImageUploaderGetID: ObservableObject {
// MARK: -
/// URL
private func getUploadURL(
for imageData: Data,
completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void
) {
let fileName = "avatar_\(UUID().uuidString).jpg"
let parameters: [String: Any] = [
"filename": fileName,
"content_type": "image/jpeg",
"file_size": imageData.count
]
let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
guard let url = URL(string: urlString) else {
completion(.failure(UploadError.invalidURL))
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = apiConfig.authHeaders
do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB")
} catch {
print("❌ 序列化请求参数失败: \(error.localizedDescription)")
completion(.failure(error))
return
}
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(UploadError.uploadFailed(error)))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(UploadError.invalidResponse))
return
}
guard let data = data else {
completion(.failure(UploadError.invalidResponse))
return
}
//
if let responseString = String(data: data, encoding: .utf8) {
print("📥 获取上传URL响应: \(responseString)")
}
do {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataDict = json["data"] as? [String: Any],
let fileId = dataDict["file_id"] as? String,
let uploadURLString = dataDict["upload_url"] as? String,
let uploadURL = URL(string: uploadURLString) else {
throw UploadError.invalidResponse
}
completion(.success((fileId: fileId, uploadURL: uploadURL)))
} catch {
completion(.failure(UploadError.invalidResponse))
}
}
task.resume()
}
/// URL
private func getUploadURL(
for fileData: Data,
@ -291,7 +227,14 @@ public class ImageUploaderGetID: ObservableObject {
]
let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
print("🌐 准备请求上传URL...")
print(" - 目标URL: \(urlString)")
print(" - 文件名: \(fileName)")
print(" - 文件大小: \(Double(fileData.count) / 1024.0) KB")
print(" - MIME类型: \(mimeType)")
guard let url = URL(string: urlString) else {
print("❌ 错误: 无效的URL: \(urlString)")
completion(.failure(UploadError.invalidURL))
return
}
@ -302,7 +245,9 @@ public class ImageUploaderGetID: ObservableObject {
do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(fileData.count) / 1024.0) KB")
if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) {
print("📤 请求体: \(bodyString)")
}
} catch {
print("❌ 序列化请求参数失败: \(error.localizedDescription)")
completion(.failure(error))
@ -311,37 +256,61 @@ public class ImageUploaderGetID: ObservableObject {
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
print("❌ 获取上传URL请求失败: \(error.localizedDescription)")
completion(.failure(UploadError.uploadFailed(error)))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
print("❌ 无效的服务器响应")
completion(.failure(UploadError.invalidResponse))
return
}
print("📥 收到上传URL响应")
print(" - 状态码: \(httpResponse.statusCode)")
guard let data = data else {
print("❌ 响应数据为空")
completion(.failure(UploadError.invalidResponse))
return
}
//
//
print(" - 响应头: \(httpResponse.allHeaderFields)")
//
if let responseString = String(data: data, encoding: .utf8) {
print("📥 上传URL响应: \(responseString)")
print(" - 响应体: \(responseString)")
}
do {
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard let code = json?["code"] as? Int, code == 0,
let dataDict = json?["data"] as? [String: Any],
print(" - 解析的JSON: \(String(describing: json))")
guard let code = json?["code"] as? Int, code == 0 else {
let errorMessage = json?["message"] as? String ?? "未知错误"
print("❌ 服务器返回错误: \(errorMessage)")
completion(.failure(UploadError.serverError(errorMessage)))
return
}
guard let dataDict = json?["data"] as? [String: Any],
let fileId = dataDict["file_id"] as? String,
let uploadURLString = dataDict["upload_url"] as? String,
let uploadURL = URL(string: uploadURLString) else {
throw UploadError.invalidResponse
print("❌ 响应数据格式错误")
completion(.failure(UploadError.invalidResponse))
return
}
print("✅ 成功获取上传URL")
print(" - 文件ID: \(fileId)")
print(" - 上传URL: \(uploadURLString)")
completion(.success((fileId: fileId, uploadURL: uploadURL)))
} catch {
print("❌ 解析响应数据失败: \(error.localizedDescription)")
completion(.failure(UploadError.invalidResponse))
}
}
@ -421,75 +390,61 @@ public class ImageUploaderGetID: ObservableObject {
public func uploadFile(
fileData: Data,
to uploadURL: URL,
mimeType: String = "application/octet-stream",
mimeType: String,
onProgress: @escaping (Double) -> Void,
completion: @escaping (Result<Void, Error>) -> Void
) -> URLSessionUploadTask {
print("📤 开始上传文件...")
var request = URLRequest(url: uploadURL)
request.httpMethod = "PUT"
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
let task = session.uploadTask(with: request, from: fileData) { _, response, error in
let task = session.uploadTask(
with: request,
from: fileData
) { data, response, error in
if let error = error {
completion(.failure(error))
print("❌ 文件上传失败: \(error.localizedDescription)")
completion(.failure(UploadError.uploadFailed(error)))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
print("❌ 无效的响应")
completion(.failure(UploadError.invalidResponse))
return
}
guard (200...299).contains(httpResponse.statusCode) else {
let statusCode = httpResponse.statusCode
completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)")))
print("❌ 服务器返回错误状态码: \(statusCode)")
completion(.failure(UploadError.serverError("HTTP \(statusCode)")))
return
}
print("✅ 文件上传成功")
completion(.success(()))
}
//
if #available(iOS 11.0, *) {
let progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in
DispatchQueue.main.async {
onProgress(progressValue.fractionCompleted)
}
let progressObserver = task.progress.observe(\.fractionCompleted) { progress, _ in
let percentComplete = progress.fractionCompleted
print("📊 文件上传进度: \(Int(percentComplete * 100))%")
onProgress(percentComplete)
}
task.addCompletionHandler { [weak task] in
progressObserver.invalidate()
task?.progress.cancel()
}
} else {
var lastProgress: Double = 0
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
let bytesSent = task.countOfBytesSent
let totalBytes = task.countOfBytesExpectedToSend
let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0
// UI
if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 {
lastProgress = currentProgress
DispatchQueue.main.async {
onProgress(min(currentProgress, 1.0))
}
}
if currentProgress >= 1.0 {
timer.invalidate()
}
}
task.addCompletionHandler {
timer.invalidate()
}
}
//
objc_setAssociatedObject(task, &AssociatedKeys.progressObserver, progressObserver, .OBJC_ASSOCIATION_RETAIN)
task.resume()
return task
}
private struct AssociatedKeys {
static var progressObserver = "progressObserver"
}
// MARK: -
///

View File

@ -60,7 +60,7 @@ public class MediaUploadManager: ObservableObject {
///
@Published public private(set) var uploadStatus: [String: MediaUploadStatus] = [:]
///
@Published public private(set) var uploadResults: [String: String] = [:] // Store fileId as String
@Published public private(set) var uploadResults: [String: UploadResult] = [:]
private let uploader = ImageUploadService() // Use ImageUploadService
private let logger = Logger(subsystem: "com.yourapp.media", category: "MediaUploadManager")
@ -125,14 +125,8 @@ public class MediaUploadManager: ObservableObject {
}
///
public func getUploadResults() -> [String: String] {
var results: [String: String] = [:]
for (id, status) in uploadStatus {
if case .completed(let fileId) = status {
results[id] = fileId
}
}
return results
public func getUploadResults() -> [String: UploadResult] {
return uploadResults
}
///
@ -146,6 +140,21 @@ public class MediaUploadManager: ObservableObject {
// MARK: - Private Methods
///
public struct UploadResult: Codable, Equatable {
public let fileId: String
public let thumbnailId: String?
public init(fileId: String, thumbnailId: String? = nil) {
self.fileId = fileId
self.thumbnailId = thumbnailId
}
public static func == (lhs: UploadResult, rhs: UploadResult) -> Bool {
return lhs.fileId == rhs.fileId && lhs.thumbnailId == rhs.thumbnailId
}
}
private func uploadMedia(_ media: MediaType) {
logger.info("🔄 开始处理媒体: \(media.id)")
@ -175,9 +184,23 @@ public class MediaUploadManager: ObservableObject {
Task { @MainActor in
switch result {
case .success(let uploadResult):
let fileId = uploadResult.fileId
self.logger.info("✅ 上传成功 (\(media.id)): \(fileId)")
self.uploadResults[media.id] = fileId
//
let fileId: String
let thumbnailId: String?
switch uploadResult {
case .file(let result):
fileId = result.fileId
thumbnailId = nil
case .video(let video, let thumbnail):
fileId = video.fileId
thumbnailId = thumbnail?.fileId
}
//
let result = UploadResult(fileId: fileId, thumbnailId: thumbnailId)
self.uploadResults[media.id] = result
self.logger.info("✅ 上传成功 (\(media.id)): \(fileId), 缩略图ID: \(thumbnailId ?? "")")
self.updateStatus(for: media.id, status: .completed(fileId: fileId))
//
@ -205,8 +228,8 @@ public class MediaUploadManager: ObservableObject {
let results = self.selectedMedia.compactMap { media -> [String: String]? in
guard let result = self.uploadResults[media.id] else { return nil }
return [
"file_id": result,
"preview_file_id": result
"file_id": result.fileId,
"preview_file_id": result.thumbnailId ?? result.fileId
]
}

View File

@ -8,23 +8,38 @@ struct MaterialResponse: Decodable {
struct MaterialData: Decodable {
let items: [MemoryItem]
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items
case hasMore = "has_more"
}
}
}
struct MemoryItem: Identifiable, Decodable {
let id: String
let name: String
let description: String
let name: String?
let description: String?
let fileInfo: FileInfo
let previewFileInfo: FileInfo
var title: String { name }
var subtitle: String { description }
var mediaType: MemoryMediaType { .image(fileInfo.url) }
var aspectRatio: CGFloat { 1.0 } // Default to square, adjust based on actual image dimensions if needed
var title: String { name ?? "Untitled" }
var subtitle: String { description ?? "" }
var mediaType: MemoryMediaType {
let url = fileInfo.url.lowercased()
if url.hasSuffix(".mp4") || url.hasSuffix(".mov") {
return .video(url: fileInfo.url, previewUrl: previewFileInfo.url)
} else {
return .image(fileInfo.url)
}
}
var aspectRatio: CGFloat { 1.0 }
enum CodingKeys: String, CodingKey {
case id, name, description
case fileInfo = "file_info"
case previewFileInfo = "preview_file_info"
}
}
@ -42,7 +57,7 @@ struct FileInfo: Decodable {
enum MemoryMediaType: Equatable {
case image(String)
case video(String)
case video(url: String, previewUrl: String)
}
struct MemoriesView: View {
@ -57,6 +72,9 @@ struct MemoriesView: View {
var body: some View {
NavigationView {
ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea()
Group {
if isLoading {
ProgressView()
@ -77,8 +95,15 @@ struct MemoriesView: View {
}
}
}
}
.navigationTitle("My Memories")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EmptyView()
}
}
.onAppear {
fetchMemories()
}
@ -97,9 +122,9 @@ struct MemoriesView: View {
switch result {
case .success(let response):
print("✅ Successfully fetched \(response.data.items.count) memory items")
print("✅ Successfully fetched \(response.data.items) memory items")
response.data.items.forEach { item in
print("📝 Item ID: \(item.id), Title: \(item.name), URL: \(item.fileInfo.url)")
print("📝 Item ID: \(item.id), Title: \(item.name ?? "Untitled"), URL: \(item)")
}
self.memories = response.data.items
case .failure(let error):
@ -135,33 +160,38 @@ struct MemoryCard: View {
}
}
case .video(let urlString):
if let url = URL(string: urlString) {
VideoPlayer(player: AVPlayer(url: url))
.aspectRatio(memory.aspectRatio, contentMode: .fill)
.onAppear {
// The video will be shown with a play button overlay
// and will only play when tapped
case .video(let url, let previewUrl):
// Use preview image for video
if let previewUrl = URL(string: previewUrl) {
AsyncImage(url: previewUrl) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else if phase.error != nil {
Color.gray.opacity(0.3)
} else {
ProgressView()
}
}
} else {
Color.gray.opacity(0.3)
.aspectRatio(memory.aspectRatio, contentMode: .fill)
}
}
}
.frame(width: (UIScreen.main.bounds.width / 2) - 24, height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio))
.frame(width: (UIScreen.main.bounds.width / 2) - 24,
height: (UIScreen.main.bounds.width / 2 - 24) * (1/memory.aspectRatio))
.clipped()
.cornerRadius(12)
.overlay(
Group {
// Show play button for videos
if case .video = memory.mediaType {
Image(systemName: "play.circle.fill")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.9))
.shadow(radius: 3)
}
}
)
}
// Title and Subtitle
VStack(alignment: .leading, spacing: 1) {

View File

@ -1,5 +1,8 @@
import SwiftUI
import AVFoundation
import PhotosUI
import AVKit
import CoreTransferable
import CoreImage.CIFilterBuiltins
extension Notification.Name {
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
@ -296,33 +299,33 @@ struct MediaUploadView: View {
}
///
private func handleUploadCompletion(results: [String: String]) {
uploadedFileIds = results.map { ["file_id": $0.value, "preview_file_id": $0.value] }
uploadComplete = !uploadedFileIds.isEmpty
//
if let jsonData = try? JSONSerialization.data(withJSONObject: uploadedFileIds, options: .prettyPrinted),
let jsonString = String(data: jsonData, encoding: .utf8) {
print("📦 上传完成文件ID列表:")
print(jsonString)
private func handleUploadCompletion(results: [String: MediaUploadManager.UploadResult]) {
//
let formattedResults = results.map { (_, result) -> [String: String] in
return [
"file_id": result.fileId,
"preview_file_id": result.thumbnailId ?? result.fileId
]
}
uploadedFileIds = formattedResults
uploadComplete = !uploadedFileIds.isEmpty
}
///
private func handleContinue() {
// ID
let fileIds = uploadManager.uploadResults.map { $0.value }
guard !fileIds.isEmpty else {
//
let uploadResults = uploadManager.uploadResults
guard !uploadResults.isEmpty else {
print("⚠️ 没有可用的文件ID")
return
}
//
let files = fileIds.map { fileId -> [String: String] in
let files = uploadResults.map { (_, result) -> [String: String] in
return [
"file_id": fileId,
"preview_file_id": fileId
"file_id": result.fileId,
"preview_file_id": result.thumbnailId ?? result.fileId
]
}