Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

303 lines
14 KiB
Swift

import Foundation
import UIKit
import Photos
import ImageIO
import SwiftSignalKit
import TelegramCore
import Postbox
public enum MediaEditorCollageDraftSaveError: Error, Equatable {
case cancelled
case mediaUnavailable
case storage
}
public final class MediaEditorCollageDraftSaveOperation {
private enum State {
case preparing
case committing
case finished
case cancelled
}
private let queue = Queue()
private let lock = NSLock()
private var state: State = .preparing
private var imageRequestId: PHImageRequestID?
private let collage: MediaEditorCollage
private let preview: UIImage
private let baseImagePath: String?
private let makeDraft: (String, MediaEditorCollageDraft, String?, String?) -> MediaEditorDraft
private let additionalVideoPath: String?
private let audioPath: String?
private let store: (MediaEditorDraft) -> Signal<Never, NoError>
private let completion: (Result<MediaEditorDraft, MediaEditorCollageDraftSaveError>) -> Void
private let directory: String
private let stagingPath: String
private let finalPath: String
private var stagingLease: MediaEditorDraftFileLease?
private var finalLease: MediaEditorDraftFileLease?
private var importedItems: [MediaEditorCollageDraft.Item] = []
private var storeDisposable: Disposable?
public init(engine: TelegramEngine, collage: MediaEditorCollage, preview: UIImage, baseImagePath: String?, additionalVideoPath: String?, audioPath: String?, makeDraft: @escaping (String, MediaEditorCollageDraft, String?, String?) -> MediaEditorDraft, store: @escaping (MediaEditorDraft) -> Signal<Never, NoError>, completion: @escaping (Result<MediaEditorDraft, MediaEditorCollageDraftSaveError>) -> Void) {
self.collage = collage
self.preview = preview
self.baseImagePath = baseImagePath
self.makeDraft = makeDraft
self.additionalVideoPath = additionalVideoPath
self.audioPath = audioPath.map { fullDraftPath(peerId: engine.account.peerId, path: $0) }
self.store = store
self.completion = completion
let id = UUID().uuidString
self.directory = "collages/\(id)"
self.stagingPath = fullDraftPath(peerId: engine.account.peerId, path: "collages/.pending-\(id)")
self.finalPath = fullDraftPath(peerId: engine.account.peerId, path: self.directory)
self.stagingLease = MediaEditorDraftFileLease(path: self.stagingPath)
self.finalLease = MediaEditorDraftFileLease(path: self.finalPath)
self.queue.async {
do {
try FileManager.default.createDirectory(atPath: self.stagingPath, withIntermediateDirectories: true)
self.importNext()
} catch {
self.finish(.failure(.storage))
}
}
}
@discardableResult public func cancel() -> Bool {
self.lock.lock()
guard self.state == .preparing else {
self.lock.unlock()
return false
}
self.state = .cancelled
let requestId = self.imageRequestId
self.lock.unlock()
if let requestId {
PHImageManager.default().cancelImageRequest(requestId)
}
self.queue.async {
self.finish(.failure(.cancelled))
}
return true
}
private var isPreparing: Bool {
self.lock.lock()
defer { self.lock.unlock() }
return self.state == .preparing
}
private func importNext() {
guard self.isPreparing else {
return
}
guard self.importedItems.count < self.collage.items.count else {
self.commit()
return
}
let item = self.collage.items[self.importedItems.count]
switch item.source {
case let .image(image, assetIdentifier):
if let assetIdentifier {
let fetchOptions = PHFetchOptions()
fetchOptions.includeHiddenAssets = true
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentifier], options: fetchOptions).firstObject else {
self.finish(.failure(.mediaUnavailable))
return
}
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
let received: (Data?) -> Void = { data in
self.queue.async {
guard self.isPreparing else {
return
}
guard let data, UIImage(data: data) != nil else {
self.finish(.failure(.mediaUnavailable))
return
}
self.writeImage(data, item: item)
}
}
let requestId: PHImageRequestID
if #available(iOS 13.0, *) {
requestId = PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, info in
guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else {
return
}
received(data)
}
} else {
requestId = PHImageManager.default().requestImageData(for: asset, options: options) { data, _, _, info in
guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else {
return
}
received(data)
}
}
self.lock.lock()
self.imageRequestId = requestId
let wasCancelled = self.state == .cancelled
self.lock.unlock()
if wasCancelled {
PHImageManager.default().cancelImageRequest(requestId)
}
} else if let data = image.pngData() {
self.writeImage(data, item: item)
} else {
self.finish(.failure(.storage))
}
case let .imageFile(path):
self.copyFile(path, name: "photo-\(item.id).image", kind: .image, item: item)
case let .videoFile(path):
let pathExtension = URL(fileURLWithPath: path).pathExtension
self.copyFile(path, name: "video-\(item.id).\(pathExtension.isEmpty ? "mp4" : pathExtension)", kind: .videoFile, item: item)
case let .videoAsset(asset):
let fetchOptions = PHFetchOptions()
fetchOptions.includeHiddenAssets = true
guard PHAsset.fetchAssets(withLocalIdentifiers: [asset.localIdentifier], options: fetchOptions).firstObject != nil else {
self.finish(.failure(.mediaUnavailable))
return
}
self.importedItems.append(MediaEditorCollageDraft.Item(item: item, kind: .videoAsset, resource: asset.localIdentifier))
self.importNext()
}
}
private func writeImage(_ data: Data, item: MediaEditorCollage.Item) {
let name = "photo-\(item.id).image"
do {
try data.write(to: URL(fileURLWithPath: self.stagingPath + "/" + name), options: .atomic)
if self.collage.isVideo {
try self.writeRenderImage(name: name, item: item)
}
self.importedItems.append(MediaEditorCollageDraft.Item(item: item, kind: .image, resource: name, dimensions: self.imageDimensions(name: name)))
self.importNext()
} catch {
self.finish(.failure(.storage))
}
}
private func copyFile(_ path: String, name: String, kind: MediaEditorCollageDraft.Item.Kind, item: MediaEditorCollage.Item) {
do {
try FileManager.default.copyItem(atPath: path, toPath: self.stagingPath + "/" + name)
if kind == .image, self.collage.isVideo {
let renderName = "render-\(item.id).jpg"
let renderPath = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(renderName).path
if FileManager.default.fileExists(atPath: renderPath) {
try FileManager.default.copyItem(atPath: renderPath, toPath: self.stagingPath + "/" + renderName)
} else {
try self.writeRenderImage(name: name, item: item)
}
}
self.importedItems.append(MediaEditorCollageDraft.Item(item: item, kind: kind, resource: name, dimensions: kind == .image ? self.imageDimensions(name: name) : nil))
self.importNext()
} catch {
self.finish(.failure(FileManager.default.fileExists(atPath: path) ? .storage : .mediaUnavailable))
}
}
private func writeRenderImage(name: String, item: MediaEditorCollage.Item) throws {
guard let image = mediaEditorCollageImage(path: self.stagingPath + "/" + name), let data = image.jpegData(compressionQuality: 0.95) else {
throw MediaEditorCollageDraftSaveError.storage
}
try data.write(to: URL(fileURLWithPath: self.stagingPath + "/render-\(item.id).jpg"), options: .atomic)
}
private func imageDimensions(name: String) -> CGSize? {
guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: self.stagingPath + "/" + name) as CFURL, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, let height = properties[kCGImagePropertyPixelHeight] as? NSNumber else {
return nil
}
let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1
if (5 ... 8).contains(orientation) {
return CGSize(width: CGFloat(height.doubleValue), height: CGFloat(width.doubleValue))
}
return CGSize(width: CGFloat(width.doubleValue), height: CGFloat(height.doubleValue))
}
private func commit() {
do {
guard let data = self.preview.jpegData(compressionQuality: 0.87) else {
self.finish(.failure(.storage))
return
}
try data.write(to: URL(fileURLWithPath: self.stagingPath + "/preview.jpg"), options: .atomic)
let baseName: String
if !self.collage.isVideo {
baseName = "base.jpg"
if let baseImagePath = self.baseImagePath, FileManager.default.fileExists(atPath: baseImagePath) {
try FileManager.default.copyItem(atPath: baseImagePath, toPath: self.stagingPath + "/" + baseName)
} else {
let items = zip(self.collage.items, self.importedItems).map { item, saved in
item.withSource(.imageFile(self.stagingPath + "/" + saved.resource))
}
guard let image = MediaEditorCollage(rows: self.collage.rows, items: items).image(), let data = image.jpegData(compressionQuality: 0.95) else {
self.finish(.failure(.storage))
return
}
try data.write(to: URL(fileURLWithPath: self.stagingPath + "/" + baseName), options: .atomic)
}
} else {
baseName = "preview.jpg"
}
let collage = MediaEditorCollageDraft(directory: self.directory, collage: self.collage, items: self.importedItems)
let additionalVideoPath = try self.copyAuxiliaryFile(self.additionalVideoPath, name: "additional-video")
let audioPath = try self.copyAuxiliaryFile(self.audioPath, name: "audio")
let draft = self.makeDraft(self.directory + "/" + baseName, collage, additionalVideoPath, audioPath)
// Validate with the same codec used by the draft list and story source cache.
let metadata = try AdaptedPostboxEncoder().encode(draft)
_ = try AdaptedPostboxDecoder().decode(MediaEditorDraft.self, from: metadata)
self.lock.lock()
guard self.state == .preparing else {
self.lock.unlock()
return
}
self.state = .committing
self.lock.unlock()
try FileManager.default.moveItem(atPath: self.stagingPath, toPath: self.finalPath)
self.storeDisposable = self.store(draft).start(completed: {
self.queue.async {
self.finish(.success(draft))
}
})
} catch {
self.finish(.failure(.storage))
}
}
private func copyAuxiliaryFile(_ path: String?, name: String) throws -> String? {
guard let path else {
return nil
}
let pathExtension = URL(fileURLWithPath: path).pathExtension
let name = name + (pathExtension.isEmpty ? "" : "." + pathExtension)
try FileManager.default.copyItem(atPath: path, toPath: self.stagingPath + "/" + name)
return self.directory + "/" + name
}
private func finish(_ result: Result<MediaEditorDraft, MediaEditorCollageDraftSaveError>) {
self.lock.lock()
guard self.state != .finished else {
self.lock.unlock()
return
}
let result: Result<MediaEditorDraft, MediaEditorCollageDraftSaveError> = self.state == .cancelled ? .failure(.cancelled) : result
self.state = .finished
self.lock.unlock()
self.storeDisposable = nil
if case .failure = result {
MediaEditorDraftFileLease.delete(path: self.stagingPath)
MediaEditorDraftFileLease.delete(path: self.finalPath)
}
self.stagingLease = nil
self.finalLease = nil
Queue.mainQueue().async {
self.completion(result)
}
}
}