- 所有公开 API 改为 completion handler (Result<T, Error>) - 状态通知改为 DuooomiBleSDKDelegate 协议 - 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation - 网络请求改为 URLSession.dataTask 回调 - BLE 通信改为 delegate + 闭包模式 - deployment target 降至 iOS 12.2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
104 lines
3.5 KiB
Swift
104 lines
3.5 KiB
Swift
import Foundation
|
||
|
||
struct AniConverterError: LocalizedError {
|
||
let message: String
|
||
var errorDescription: String? { message }
|
||
}
|
||
|
||
/// 将源视频/图片转换为设备可用的 ANI 文件。
|
||
final class AniConverter {
|
||
private let config: DuooomiBleConfig
|
||
|
||
private var endpoint: URL {
|
||
URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")!
|
||
}
|
||
|
||
private let session: URLSession = {
|
||
let cfg = URLSessionConfiguration.default
|
||
cfg.timeoutIntervalForRequest = 120
|
||
cfg.timeoutIntervalForResource = 180
|
||
cfg.waitsForConnectivity = true
|
||
return URLSession(configuration: cfg)
|
||
}()
|
||
|
||
init(config: DuooomiBleConfig) {
|
||
self.config = config
|
||
}
|
||
|
||
/// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。
|
||
func convert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
|
||
performConvert(fileUrl: fileUrl) { result in
|
||
switch result {
|
||
case .success:
|
||
completion(result)
|
||
case .failure(let error):
|
||
// Retry once on connection lost
|
||
if (error as NSError).code == NSURLErrorNetworkConnectionLost {
|
||
self.performConvert(fileUrl: fileUrl, completion: completion)
|
||
} else {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func performConvert(fileUrl: String, completion: @escaping (Result<String, Error>) -> Void) {
|
||
var request = URLRequest(url: endpoint)
|
||
request.httpMethod = "POST"
|
||
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
||
request.setValue(config.apiKey, forHTTPHeaderField: "x-api-key")
|
||
request.timeoutInterval = 120
|
||
|
||
let body: [String: Any] = [
|
||
"videoUrl": fileUrl,
|
||
"width": config.aniWidth,
|
||
"height": config.aniHeight,
|
||
"fps": config.aniFps,
|
||
]
|
||
|
||
do {
|
||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||
} catch {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
session.dataTask(with: request) { data, response, error in
|
||
if let error = error {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||
completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)")))
|
||
return
|
||
}
|
||
|
||
guard let data = data,
|
||
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
||
completion(.failure(AniConverterError(message: "Invalid JSON response")))
|
||
return
|
||
}
|
||
|
||
guard (json["success"] as? Bool) == true,
|
||
let outer = json["data"] as? [String: Any] else {
|
||
completion(.failure(AniConverterError(message: "ANI API reported failure")))
|
||
return
|
||
}
|
||
|
||
if (outer["status"] as? Bool) != true {
|
||
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
|
||
completion(.failure(AniConverterError(message: msg)))
|
||
return
|
||
}
|
||
|
||
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
|
||
completion(.failure(AniConverterError(message: "Missing ani url in response")))
|
||
return
|
||
}
|
||
|
||
completion(.success(aniUrl))
|
||
}.resume()
|
||
}
|
||
}
|