mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
[WIP] Stickers editor
This commit is contained in:
parent
d4c13120f8
commit
3e3b04e495
@ -11742,7 +11742,7 @@ Sorry for the inconvenience.";
|
|||||||
"ReportAd.Help" = "Learn more about [Telegram Ad Policies and Guidelines]().";
|
"ReportAd.Help" = "Learn more about [Telegram Ad Policies and Guidelines]().";
|
||||||
"ReportAd.Help_URL" = "https://ads.telegram.org/guidelines";
|
"ReportAd.Help_URL" = "https://ads.telegram.org/guidelines";
|
||||||
"ReportAd.Reported" = "We will review this ad to ensure it matches our [Ad Policies and Guidelines]().";
|
"ReportAd.Reported" = "We will review this ad to ensure it matches our [Ad Policies and Guidelines]().";
|
||||||
"ReportAd.Hidden" = "Ads are hidden now.";
|
"ReportAd.Hidden" = "You will no longer see ads from Telegram.";
|
||||||
|
|
||||||
"AdsInfo.Title" = "About These Ads";
|
"AdsInfo.Title" = "About These Ads";
|
||||||
"AdsInfo.Info" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:";
|
"AdsInfo.Info" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:";
|
||||||
|
@ -287,6 +287,7 @@ public final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource {
|
|||||||
private let bytesPerRow: Int
|
private let bytesPerRow: Int
|
||||||
public var frameCount: Int
|
public var frameCount: Int
|
||||||
public let frameRate: Int
|
public let frameRate: Int
|
||||||
|
public var duration: Double
|
||||||
fileprivate var currentFrame: Int
|
fileprivate var currentFrame: Int
|
||||||
|
|
||||||
private let source: SoftwareVideoSource?
|
private let source: SoftwareVideoSource?
|
||||||
@ -316,17 +317,24 @@ public final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource {
|
|||||||
self.image = nil
|
self.image = nil
|
||||||
self.frameRate = Int(cache.frameRate)
|
self.frameRate = Int(cache.frameRate)
|
||||||
self.frameCount = Int(cache.frameCount)
|
self.frameCount = Int(cache.frameCount)
|
||||||
|
if self.frameRate > 0 {
|
||||||
|
self.duration = Double(self.frameCount) / Double(self.frameRate)
|
||||||
|
} else {
|
||||||
|
self.duration = 0.0
|
||||||
|
}
|
||||||
} else if let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let image = WebP.convert(fromWebP: data) {
|
} else if let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let image = WebP.convert(fromWebP: data) {
|
||||||
self.source = nil
|
self.source = nil
|
||||||
self.image = image
|
self.image = image
|
||||||
self.frameRate = 1
|
self.frameRate = 1
|
||||||
self.frameCount = 1
|
self.frameCount = 1
|
||||||
|
self.duration = 0.0
|
||||||
} else {
|
} else {
|
||||||
let source = SoftwareVideoSource(path: path, hintVP9: true, unpremultiplyAlpha: unpremultiplyAlpha)
|
let source = SoftwareVideoSource(path: path, hintVP9: true, unpremultiplyAlpha: unpremultiplyAlpha)
|
||||||
self.source = source
|
self.source = source
|
||||||
self.image = nil
|
self.image = nil
|
||||||
self.frameRate = min(30, source.getFramerate())
|
self.frameRate = min(30, source.getFramerate())
|
||||||
self.frameCount = 0
|
self.frameCount = 0
|
||||||
|
self.duration = source.reportedDuration.seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,6 +43,7 @@ extern int FFMpegCodecIdVP9;
|
|||||||
- (NSArray<NSNumber *> *)streamIndicesForType:(FFMpegAVFormatStreamType)type;
|
- (NSArray<NSNumber *> *)streamIndicesForType:(FFMpegAVFormatStreamType)type;
|
||||||
- (bool)isAttachedPicAtStreamIndex:(int32_t)streamIndex;
|
- (bool)isAttachedPicAtStreamIndex:(int32_t)streamIndex;
|
||||||
- (int)codecIdAtStreamIndex:(int32_t)streamIndex;
|
- (int)codecIdAtStreamIndex:(int32_t)streamIndex;
|
||||||
|
- (double)duration;
|
||||||
- (int64_t)durationAtStreamIndex:(int32_t)streamIndex;
|
- (int64_t)durationAtStreamIndex:(int32_t)streamIndex;
|
||||||
- (bool)codecParamsAtStreamIndex:(int32_t)streamIndex toContext:(FFMpegAVCodecContext *)context;
|
- (bool)codecParamsAtStreamIndex:(int32_t)streamIndex toContext:(FFMpegAVCodecContext *)context;
|
||||||
- (FFMpegFpsAndTimebase)fpsAndTimebaseForStreamIndex:(int32_t)streamIndex defaultTimeBase:(CMTime)defaultTimeBase;
|
- (FFMpegFpsAndTimebase)fpsAndTimebaseForStreamIndex:(int32_t)streamIndex defaultTimeBase:(CMTime)defaultTimeBase;
|
||||||
|
@ -99,6 +99,10 @@ int FFMpegCodecIdVP9 = AV_CODEC_ID_VP9;
|
|||||||
return _impl->streams[streamIndex]->codecpar->codec_id;
|
return _impl->streams[streamIndex]->codecpar->codec_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (double)duration {
|
||||||
|
return (double)_impl->duration / AV_TIME_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
- (int64_t)durationAtStreamIndex:(int32_t)streamIndex {
|
- (int64_t)durationAtStreamIndex:(int32_t)streamIndex {
|
||||||
return _impl->streams[streamIndex]->duration;
|
return _impl->streams[streamIndex]->duration;
|
||||||
}
|
}
|
||||||
|
@ -69,6 +69,8 @@ public final class SoftwareVideoSource {
|
|||||||
private var enqueuedFrames: [(MediaTrackFrame, CGFloat, CGFloat, Bool)] = []
|
private var enqueuedFrames: [(MediaTrackFrame, CGFloat, CGFloat, Bool)] = []
|
||||||
private var hasReadToEnd: Bool = false
|
private var hasReadToEnd: Bool = false
|
||||||
|
|
||||||
|
public private(set) var reportedDuration: CMTime = .invalid
|
||||||
|
|
||||||
public init(path: String, hintVP9: Bool, unpremultiplyAlpha: Bool) {
|
public init(path: String, hintVP9: Bool, unpremultiplyAlpha: Bool) {
|
||||||
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
||||||
|
|
||||||
@ -142,6 +144,8 @@ public final class SoftwareVideoSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.reportedDuration = CMTime(seconds: avFormatContext.duration(), preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||||
|
|
||||||
self.videoStream = videoStream
|
self.videoStream = videoStream
|
||||||
|
|
||||||
if let videoStream = self.videoStream {
|
if let videoStream = self.videoStream {
|
||||||
|
@ -1107,61 +1107,7 @@ private final class DemoSheetContent: CombinedComponent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
var measuredTextHeight: CGFloat?
|
var measuredTextHeight: CGFloat?
|
||||||
|
|
||||||
let buttonText: String
|
|
||||||
var buttonAnimationName: String?
|
|
||||||
if state.isPremium == true {
|
|
||||||
buttonText = strings.Common_OK
|
|
||||||
} else {
|
|
||||||
switch component.source {
|
|
||||||
case let .intro(price):
|
|
||||||
buttonText = strings.Premium_SubscribeFor(price ?? "–").string
|
|
||||||
case let .gift(price):
|
|
||||||
buttonText = strings.Premium_Gift_GiftSubscription(price ?? "–").string
|
|
||||||
case .other:
|
|
||||||
var text: String
|
var text: String
|
||||||
switch component.subject {
|
|
||||||
case .fasterDownload:
|
|
||||||
buttonText = strings.Premium_FasterSpeed_Proceed
|
|
||||||
case .advancedChatManagement:
|
|
||||||
buttonText = strings.Premium_ChatManagement_Proceed
|
|
||||||
case .uniqueReactions:
|
|
||||||
buttonText = strings.Premium_Reactions_Proceed
|
|
||||||
buttonAnimationName = "premium_unlock"
|
|
||||||
case .premiumStickers:
|
|
||||||
buttonText = strings.Premium_Stickers_Proceed
|
|
||||||
buttonAnimationName = "premium_unlock"
|
|
||||||
case .appIcons:
|
|
||||||
buttonText = strings.Premium_AppIcons_Proceed
|
|
||||||
buttonAnimationName = "premium_unlock"
|
|
||||||
case .noAds:
|
|
||||||
buttonText = strings.Premium_NoAds_Proceed
|
|
||||||
case .animatedEmoji:
|
|
||||||
buttonText = strings.Premium_AnimatedEmoji_Proceed
|
|
||||||
buttonAnimationName = "premium_unlock"
|
|
||||||
case .translation:
|
|
||||||
buttonText = strings.Premium_Translation_Proceed
|
|
||||||
case .stories:
|
|
||||||
buttonText = strings.Common_OK
|
|
||||||
buttonAnimationName = "premium_unlock"
|
|
||||||
case .voiceToText:
|
|
||||||
buttonText = strings.Premium_VoiceToText_Proceed
|
|
||||||
case .wallpapers:
|
|
||||||
buttonText = strings.Premium_Wallpaper_Proceed
|
|
||||||
case .colors:
|
|
||||||
buttonText = strings.Premium_Colors_Proceed
|
|
||||||
case .messageTags:
|
|
||||||
buttonText = strings.Premium_MessageTags_Proceed
|
|
||||||
case .lastSeen:
|
|
||||||
buttonText = strings.Premium_LastSeen_Proceed
|
|
||||||
case .messagePrivacy:
|
|
||||||
buttonText = strings.Premium_MessagePrivacy_Proceed
|
|
||||||
case .folderTags:
|
|
||||||
buttonText = strings.Premium_FolderTags_Proceed
|
|
||||||
default:
|
|
||||||
buttonText = strings.Common_OK
|
|
||||||
}
|
|
||||||
|
|
||||||
switch component.subject {
|
switch component.subject {
|
||||||
case .moreUpload:
|
case .moreUpload:
|
||||||
text = strings.Premium_UploadSizeInfo
|
text = strings.Premium_UploadSizeInfo
|
||||||
@ -1232,6 +1178,59 @@ private final class DemoSheetContent: CombinedComponent {
|
|||||||
.position(CGPoint(x: 0.0, y: 1000.0))
|
.position(CGPoint(x: 0.0, y: 1000.0))
|
||||||
)
|
)
|
||||||
measuredTextHeight = measureText.size.height
|
measuredTextHeight = measureText.size.height
|
||||||
|
|
||||||
|
let buttonText: String
|
||||||
|
var buttonAnimationName: String?
|
||||||
|
if state.isPremium == true {
|
||||||
|
buttonText = strings.Common_OK
|
||||||
|
} else {
|
||||||
|
switch component.source {
|
||||||
|
case let .intro(price):
|
||||||
|
buttonText = strings.Premium_SubscribeFor(price ?? "–").string
|
||||||
|
case let .gift(price):
|
||||||
|
buttonText = strings.Premium_Gift_GiftSubscription(price ?? "–").string
|
||||||
|
case .other:
|
||||||
|
switch component.subject {
|
||||||
|
case .fasterDownload:
|
||||||
|
buttonText = strings.Premium_FasterSpeed_Proceed
|
||||||
|
case .advancedChatManagement:
|
||||||
|
buttonText = strings.Premium_ChatManagement_Proceed
|
||||||
|
case .uniqueReactions:
|
||||||
|
buttonText = strings.Premium_Reactions_Proceed
|
||||||
|
buttonAnimationName = "premium_unlock"
|
||||||
|
case .premiumStickers:
|
||||||
|
buttonText = strings.Premium_Stickers_Proceed
|
||||||
|
buttonAnimationName = "premium_unlock"
|
||||||
|
case .appIcons:
|
||||||
|
buttonText = strings.Premium_AppIcons_Proceed
|
||||||
|
buttonAnimationName = "premium_unlock"
|
||||||
|
case .noAds:
|
||||||
|
buttonText = strings.Premium_NoAds_Proceed
|
||||||
|
case .animatedEmoji:
|
||||||
|
buttonText = strings.Premium_AnimatedEmoji_Proceed
|
||||||
|
buttonAnimationName = "premium_unlock"
|
||||||
|
case .translation:
|
||||||
|
buttonText = strings.Premium_Translation_Proceed
|
||||||
|
case .stories:
|
||||||
|
buttonText = strings.Common_OK
|
||||||
|
buttonAnimationName = "premium_unlock"
|
||||||
|
case .voiceToText:
|
||||||
|
buttonText = strings.Premium_VoiceToText_Proceed
|
||||||
|
case .wallpapers:
|
||||||
|
buttonText = strings.Premium_Wallpaper_Proceed
|
||||||
|
case .colors:
|
||||||
|
buttonText = strings.Premium_Colors_Proceed
|
||||||
|
case .messageTags:
|
||||||
|
buttonText = strings.Premium_MessageTags_Proceed
|
||||||
|
case .lastSeen:
|
||||||
|
buttonText = strings.Premium_LastSeen_Proceed
|
||||||
|
case .messagePrivacy:
|
||||||
|
buttonText = strings.Premium_MessagePrivacy_Proceed
|
||||||
|
case .folderTags:
|
||||||
|
buttonText = strings.Premium_FolderTags_Proceed
|
||||||
|
default:
|
||||||
|
buttonText = strings.Common_OK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,6 +90,9 @@ func _internal_uploadSticker(account: Account, peer: Peer, resource: MediaResour
|
|||||||
case let .messageMediaDocument(_, document, _, _):
|
case let .messageMediaDocument(_, document, _, _):
|
||||||
if let document = document, let file = telegramMediaFileFromApiDocument(document), let uploadedResource = file.resource as? CloudDocumentMediaResource {
|
if let document = document, let file = telegramMediaFileFromApiDocument(document), let uploadedResource = file.resource as? CloudDocumentMediaResource {
|
||||||
account.postbox.mediaBox.copyResourceData(from: resource.id, to: uploadedResource.id, synchronous: true)
|
account.postbox.mediaBox.copyResourceData(from: resource.id, to: uploadedResource.id, synchronous: true)
|
||||||
|
if let thumbnail, let previewRepresentation = file.previewRepresentations.first(where: { $0.dimensions == PixelDimensions(width: 320, height: 320) }) {
|
||||||
|
account.postbox.mediaBox.copyResourceData(from: thumbnail.id, to: previewRepresentation.resource.id, synchronous: true)
|
||||||
|
}
|
||||||
return .single(.complete(uploadedResource, file.mimeType))
|
return .single(.complete(uploadedResource, file.mimeType))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@ -338,6 +341,29 @@ public enum AddStickerToSetError {
|
|||||||
case generic
|
case generic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func revalidatedSticker<T>(account: Account, sticker: FileMediaReference, signal: @escaping (CloudDocumentMediaResource) -> Signal<T, MTRpcError>) -> Signal<T, MTRpcError> {
|
||||||
|
guard let resource = sticker.media.resource as? CloudDocumentMediaResource else {
|
||||||
|
return .fail(MTRpcError(errorCode: 500, errorDescription: "Internal"))
|
||||||
|
}
|
||||||
|
return signal(resource)
|
||||||
|
|> `catch` { error -> Signal<T, MTRpcError> in
|
||||||
|
if error.errorDescription == "FILE_REFERENCE_EXPIRED" {
|
||||||
|
return revalidateMediaResourceReference(accountPeerId: account.peerId, postbox: account.postbox, network: account.network, revalidationContext: account.mediaReferenceRevalidationContext, info: TelegramCloudMediaResourceFetchInfo(reference: sticker.resourceReference(resource), preferBackgroundReferenceRevalidation: false, continueInBackground: false), resource: resource)
|
||||||
|
|> mapError { _ -> MTRpcError in
|
||||||
|
return MTRpcError(errorCode: 500, errorDescription: "Internal")
|
||||||
|
}
|
||||||
|
|> mapToSignal { result -> Signal<T, MTRpcError> in
|
||||||
|
guard let resource = result.updatedResource as? CloudDocumentMediaResource else {
|
||||||
|
return .fail(MTRpcError(errorCode: 500, errorDescription: "Internal"))
|
||||||
|
}
|
||||||
|
return signal(resource)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .fail(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _internal_addStickerToStickerSet(account: Account, packReference: StickerPackReference, sticker: ImportSticker) -> Signal<Bool, AddStickerToSetError> {
|
func _internal_addStickerToStickerSet(account: Account, packReference: StickerPackReference, sticker: ImportSticker) -> Signal<Bool, AddStickerToSetError> {
|
||||||
let uploadSticker: Signal<UploadStickerStatus, AddStickerToSetError>
|
let uploadSticker: Signal<UploadStickerStatus, AddStickerToSetError>
|
||||||
if let resource = sticker.resource.resource as? CloudDocumentMediaResource {
|
if let resource = sticker.resource.resource as? CloudDocumentMediaResource {
|
||||||
@ -363,7 +389,6 @@ func _internal_addStickerToStickerSet(account: Account, packReference: StickerPa
|
|||||||
flags |= (1 << 1)
|
flags |= (1 << 1)
|
||||||
}
|
}
|
||||||
let inputSticker: Api.InputStickerSetItem = .inputStickerSetItem(flags: flags, document: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), emoji: sticker.emojis.joined(), maskCoords: nil, keywords: sticker.keywords)
|
let inputSticker: Api.InputStickerSetItem = .inputStickerSetItem(flags: flags, document: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), emoji: sticker.emojis.joined(), maskCoords: nil, keywords: sticker.keywords)
|
||||||
|
|
||||||
return account.network.request(Api.functions.stickers.addStickerToSet(stickerset: packReference.apiInputStickerSet, sticker: inputSticker))
|
return account.network.request(Api.functions.stickers.addStickerToSet(stickerset: packReference.apiInputStickerSet, sticker: inputSticker))
|
||||||
|> `catch` { error -> Signal<Api.messages.StickerSet, MTRpcError> in
|
|> `catch` { error -> Signal<Api.messages.StickerSet, MTRpcError> in
|
||||||
if error.errorDescription == "FILE_REFERENCE_EXPIRED" {
|
if error.errorDescription == "FILE_REFERENCE_EXPIRED" {
|
||||||
@ -408,10 +433,9 @@ public enum ReorderStickerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func _internal_reorderSticker(account: Account, sticker: FileMediaReference, position: Int) -> Signal<Never, ReorderStickerError> {
|
func _internal_reorderSticker(account: Account, sticker: FileMediaReference, position: Int) -> Signal<Never, ReorderStickerError> {
|
||||||
guard let resource = sticker.media.resource as? CloudDocumentMediaResource else {
|
return revalidatedSticker(account: account, sticker: sticker, signal: { resource in
|
||||||
return .fail(.generic)
|
|
||||||
}
|
|
||||||
return account.network.request(Api.functions.stickers.changeStickerPosition(sticker: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), position: Int32(position)))
|
return account.network.request(Api.functions.stickers.changeStickerPosition(sticker: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference)), position: Int32(position)))
|
||||||
|
})
|
||||||
|> mapError { error -> ReorderStickerError in
|
|> mapError { error -> ReorderStickerError in
|
||||||
return .generic
|
return .generic
|
||||||
}
|
}
|
||||||
@ -436,10 +460,9 @@ public enum DeleteStickerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func _internal_deleteStickerFromStickerSet(account: Account, sticker: FileMediaReference) -> Signal<Never, DeleteStickerError> {
|
func _internal_deleteStickerFromStickerSet(account: Account, sticker: FileMediaReference) -> Signal<Never, DeleteStickerError> {
|
||||||
guard let resource = sticker.media.resource as? CloudDocumentMediaResource else {
|
return revalidatedSticker(account: account, sticker: sticker, signal: { resource in
|
||||||
return .fail(.generic)
|
|
||||||
}
|
|
||||||
return account.network.request(Api.functions.stickers.removeStickerFromSet(sticker: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference))))
|
return account.network.request(Api.functions.stickers.removeStickerFromSet(sticker: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference))))
|
||||||
|
})
|
||||||
|> mapError { error -> DeleteStickerError in
|
|> mapError { error -> DeleteStickerError in
|
||||||
return .generic
|
return .generic
|
||||||
}
|
}
|
||||||
@ -463,10 +486,6 @@ public enum ReplaceStickerError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func _internal_replaceSticker(account: Account, previousSticker: FileMediaReference, sticker: ImportSticker) -> Signal<Never, ReplaceStickerError> {
|
func _internal_replaceSticker(account: Account, previousSticker: FileMediaReference, sticker: ImportSticker) -> Signal<Never, ReplaceStickerError> {
|
||||||
guard let previousResource = previousSticker.media.resource as? CloudDocumentMediaResource else {
|
|
||||||
return .fail(.generic)
|
|
||||||
}
|
|
||||||
|
|
||||||
let uploadSticker: Signal<UploadStickerStatus, ReplaceStickerError>
|
let uploadSticker: Signal<UploadStickerStatus, ReplaceStickerError>
|
||||||
if let resource = sticker.resource.resource as? CloudDocumentMediaResource {
|
if let resource = sticker.resource.resource as? CloudDocumentMediaResource {
|
||||||
uploadSticker = .single(.complete(resource, sticker.mimeType))
|
uploadSticker = .single(.complete(resource, sticker.mimeType))
|
||||||
@ -485,14 +504,14 @@ func _internal_replaceSticker(account: Account, previousSticker: FileMediaRefere
|
|||||||
guard case let .complete(resource, _) = uploadedSticker else {
|
guard case let .complete(resource, _) = uploadedSticker else {
|
||||||
return .complete()
|
return .complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
var flags: Int32 = 0
|
var flags: Int32 = 0
|
||||||
if sticker.keywords.count > 0 {
|
if sticker.keywords.count > 0 {
|
||||||
flags |= (1 << 1)
|
flags |= (1 << 1)
|
||||||
}
|
}
|
||||||
let inputSticker: Api.InputStickerSetItem = .inputStickerSetItem(flags: flags, document: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), emoji: sticker.emojis.joined(), maskCoords: nil, keywords: sticker.keywords)
|
let inputSticker: Api.InputStickerSetItem = .inputStickerSetItem(flags: flags, document: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), emoji: sticker.emojis.joined(), maskCoords: nil, keywords: sticker.keywords)
|
||||||
|
return revalidatedSticker(account: account, sticker: previousSticker, signal: { previousResource in
|
||||||
return account.network.request(Api.functions.stickers.replaceSticker(sticker: .inputDocument(id: previousResource.fileId, accessHash: previousResource.accessHash, fileReference: Buffer(data: previousResource.fileReference ?? Data())), newSticker: inputSticker))
|
return account.network.request(Api.functions.stickers.replaceSticker(sticker: .inputDocument(id: previousResource.fileId, accessHash: previousResource.accessHash, fileReference: Buffer(data: previousResource.fileReference)), newSticker: inputSticker))
|
||||||
|
})
|
||||||
|> mapError { error -> ReplaceStickerError in
|
|> mapError { error -> ReplaceStickerError in
|
||||||
return .generic
|
return .generic
|
||||||
}
|
}
|
||||||
|
@ -130,11 +130,9 @@ public final class MediaEditor {
|
|||||||
|
|
||||||
private let clock = CMClockGetHostTimeClock()
|
private let clock = CMClockGetHostTimeClock()
|
||||||
|
|
||||||
private var player: AVPlayer? {
|
private var stickerEntity: MediaEditorComposerStickerEntity?
|
||||||
didSet {
|
|
||||||
|
|
||||||
}
|
private var player: AVPlayer?
|
||||||
}
|
|
||||||
private var playerAudioMix: AVMutableAudioMix?
|
private var playerAudioMix: AVMutableAudioMix?
|
||||||
|
|
||||||
private var additionalPlayer: AVPlayer?
|
private var additionalPlayer: AVPlayer?
|
||||||
@ -209,6 +207,9 @@ public final class MediaEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var resultIsVideo: Bool {
|
public var resultIsVideo: Bool {
|
||||||
|
if case let .sticker(file) = self.subject {
|
||||||
|
return file.isAnimatedSticker || file.isVideoSticker
|
||||||
|
}
|
||||||
return self.player != nil || self.audioPlayer != nil || self.additionalPlayer != nil || self.values.entities.contains(where: { $0.entity.isAnimated })
|
return self.player != nil || self.audioPlayer != nil || self.additionalPlayer != nil || self.values.entities.contains(where: { $0.entity.isAnimated })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +263,9 @@ public final class MediaEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var duration: Double? {
|
public var duration: Double? {
|
||||||
if let _ = self.player {
|
if let stickerEntity = self.stickerEntity {
|
||||||
|
return stickerEntity.totalDuration
|
||||||
|
} else if let _ = self.player {
|
||||||
if let trimRange = self.values.videoTrimRange {
|
if let trimRange = self.values.videoTrimRange {
|
||||||
return trimRange.upperBound - trimRange.lowerBound
|
return trimRange.upperBound - trimRange.lowerBound
|
||||||
} else {
|
} else {
|
||||||
@ -506,13 +509,22 @@ public final class MediaEditor {
|
|||||||
let image: UIImage?
|
let image: UIImage?
|
||||||
let nightImage: UIImage?
|
let nightImage: UIImage?
|
||||||
let player: AVPlayer?
|
let player: AVPlayer?
|
||||||
|
let stickerEntity: MediaEditorComposerStickerEntity?
|
||||||
let playerIsReference: Bool
|
let playerIsReference: Bool
|
||||||
let gradientColors: GradientColors
|
let gradientColors: GradientColors
|
||||||
|
|
||||||
init(image: UIImage? = nil, nightImage: UIImage? = nil, player: AVPlayer? = nil, playerIsReference: Bool = false, gradientColors: GradientColors) {
|
init(
|
||||||
|
image: UIImage? = nil,
|
||||||
|
nightImage: UIImage? = nil,
|
||||||
|
player: AVPlayer? = nil,
|
||||||
|
stickerEntity: MediaEditorComposerStickerEntity? = nil,
|
||||||
|
playerIsReference: Bool = false,
|
||||||
|
gradientColors: GradientColors
|
||||||
|
) {
|
||||||
self.image = image
|
self.image = image
|
||||||
self.nightImage = nightImage
|
self.nightImage = nightImage
|
||||||
self.player = player
|
self.player = player
|
||||||
|
self.stickerEntity = stickerEntity
|
||||||
self.playerIsReference = playerIsReference
|
self.playerIsReference = playerIsReference
|
||||||
self.gradientColors = gradientColors
|
self.gradientColors = gradientColors
|
||||||
}
|
}
|
||||||
@ -661,16 +673,23 @@ public final class MediaEditor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .sticker:
|
case let .sticker(file):
|
||||||
let image = generateImage(CGSize(width: 1080, height: 1920), contextGenerator: { size, context in
|
let entity = MediaEditorComposerStickerEntity(
|
||||||
context.clear(CGRect(origin: .zero, size: size))
|
postbox: self.context.account.postbox,
|
||||||
}, opaque: false, scale: 1.0)
|
content: .file(file),
|
||||||
|
position: .zero,
|
||||||
|
scale: 1.0,
|
||||||
|
rotation: 0.0,
|
||||||
|
baseSize: CGSize(width: 512.0, height: 512.0),
|
||||||
|
mirrored: false,
|
||||||
|
colorSpace: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
tintColor: nil,
|
||||||
|
isStatic: false,
|
||||||
|
highRes: true
|
||||||
|
)
|
||||||
textureSource = .single(
|
textureSource = .single(
|
||||||
TextureSourceResult(
|
TextureSourceResult(
|
||||||
image: image,
|
stickerEntity: entity,
|
||||||
nightImage: nil,
|
|
||||||
player: nil,
|
|
||||||
playerIsReference: false,
|
|
||||||
gradientColors: GradientColors(top: .clear, bottom: .clear)
|
gradientColors: GradientColors(top: .clear, bottom: .clear)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -732,6 +751,11 @@ public final class MediaEditor {
|
|||||||
if let additionalPlayer, let playerItem = additionalPlayer.currentItem {
|
if let additionalPlayer, let playerItem = additionalPlayer.currentItem {
|
||||||
textureSource.setAdditionalInput(.video(playerItem))
|
textureSource.setAdditionalInput(.video(playerItem))
|
||||||
}
|
}
|
||||||
|
if let entity = textureSourceResult.stickerEntity {
|
||||||
|
textureSource.setMainInput(.entity(entity))
|
||||||
|
}
|
||||||
|
self.stickerEntity = textureSourceResult.stickerEntity
|
||||||
|
|
||||||
self.renderer.textureSource = textureSource
|
self.renderer.textureSource = textureSource
|
||||||
|
|
||||||
switch self.mode {
|
switch self.mode {
|
||||||
|
@ -54,6 +54,7 @@ final class MediaEditorComposer {
|
|||||||
enum Input {
|
enum Input {
|
||||||
case texture(MTLTexture, CMTime, Bool)
|
case texture(MTLTexture, CMTime, Bool)
|
||||||
case videoBuffer(VideoPixelBuffer)
|
case videoBuffer(VideoPixelBuffer)
|
||||||
|
case ciImage(CIImage, CMTime)
|
||||||
|
|
||||||
var timestamp: CMTime {
|
var timestamp: CMTime {
|
||||||
switch self {
|
switch self {
|
||||||
@ -61,30 +62,33 @@ final class MediaEditorComposer {
|
|||||||
return timestamp
|
return timestamp
|
||||||
case let .videoBuffer(videoBuffer):
|
case let .videoBuffer(videoBuffer):
|
||||||
return videoBuffer.timestamp
|
return videoBuffer.timestamp
|
||||||
|
case let .ciImage(_, timestamp):
|
||||||
|
return timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rendererInput: MediaEditorRenderer.Input {
|
var rendererInput: MediaEditorRenderer.Input {
|
||||||
switch self {
|
switch self {
|
||||||
case let .texture(texture, time, hasTransparency):
|
case let .texture(texture, timestamp, hasTransparency):
|
||||||
return .texture(texture, time, hasTransparency)
|
return .texture(texture, timestamp, hasTransparency)
|
||||||
case let .videoBuffer(videoBuffer):
|
case let .videoBuffer(videoBuffer):
|
||||||
return .videoBuffer(videoBuffer)
|
return .videoBuffer(videoBuffer)
|
||||||
|
case let .ciImage(image, timestamp):
|
||||||
|
return .ciImage(image, timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let device: MTLDevice?
|
let device: MTLDevice?
|
||||||
private let colorSpace: CGColorSpace
|
let colorSpace: CGColorSpace
|
||||||
|
let ciContext: CIContext?
|
||||||
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
private let values: MediaEditorValues
|
private let values: MediaEditorValues
|
||||||
private let dimensions: CGSize
|
private let dimensions: CGSize
|
||||||
private let outputDimensions: CGSize
|
private let outputDimensions: CGSize
|
||||||
private let textScale: CGFloat
|
private let textScale: CGFloat
|
||||||
|
|
||||||
private let ciContext: CIContext?
|
|
||||||
private var textureCache: CVMetalTextureCache?
|
|
||||||
|
|
||||||
private let renderer = MediaEditorRenderer()
|
private let renderer = MediaEditorRenderer()
|
||||||
private let renderChain = MediaEditorRenderChain()
|
private let renderChain = MediaEditorRenderChain()
|
||||||
|
|
||||||
|
@ -153,7 +153,7 @@ private class MediaEditorComposerStaticEntity: MediaEditorComposerEntity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
final class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
||||||
public enum Content {
|
public enum Content {
|
||||||
case file(TelegramMediaFile)
|
case file(TelegramMediaFile)
|
||||||
case video(TelegramMediaFile)
|
case video(TelegramMediaFile)
|
||||||
@ -203,7 +203,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
|||||||
var imagePixelBuffer: CVPixelBuffer?
|
var imagePixelBuffer: CVPixelBuffer?
|
||||||
let imagePromise = Promise<UIImage>()
|
let imagePromise = Promise<UIImage>()
|
||||||
|
|
||||||
init(postbox: Postbox, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace, tintColor: UIColor?, isStatic: Bool) {
|
init(postbox: Postbox, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace, tintColor: UIColor?, isStatic: Bool, highRes: Bool = false) {
|
||||||
self.postbox = postbox
|
self.postbox = postbox
|
||||||
self.content = content
|
self.content = content
|
||||||
self.position = position
|
self.position = position
|
||||||
@ -226,7 +226,9 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
|||||||
let pathPrefix = postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id)
|
let pathPrefix = postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id)
|
||||||
if let source = self.source {
|
if let source = self.source {
|
||||||
let fitToSize: CGSize
|
let fitToSize: CGSize
|
||||||
if self.isStatic {
|
if highRes {
|
||||||
|
fitToSize = CGSize(width: 512, height: 512)
|
||||||
|
} else if self.isStatic {
|
||||||
fitToSize = CGSize(width: 768, height: 768)
|
fitToSize = CGSize(width: 768, height: 768)
|
||||||
} else {
|
} else {
|
||||||
fitToSize = CGSize(width: 384, height: 384)
|
fitToSize = CGSize(width: 384, height: 384)
|
||||||
@ -246,7 +248,12 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
|||||||
strongSelf.frameCount = frameSource.frameCount
|
strongSelf.frameCount = frameSource.frameCount
|
||||||
strongSelf.frameRate = frameSource.frameRate
|
strongSelf.frameRate = frameSource.frameRate
|
||||||
|
|
||||||
let duration = Double(frameSource.frameCount) / Double(frameSource.frameRate)
|
let duration: Double
|
||||||
|
if frameSource.frameCount > 0 {
|
||||||
|
duration = Double(frameSource.frameCount) / Double(frameSource.frameRate)
|
||||||
|
} else {
|
||||||
|
duration = frameSource.duration
|
||||||
|
}
|
||||||
strongSelf.totalDuration = duration
|
strongSelf.totalDuration = duration
|
||||||
strongSelf.durationPromise.set(.single(duration))
|
strongSelf.durationPromise.set(.single(duration))
|
||||||
}
|
}
|
||||||
@ -489,7 +496,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
|
|||||||
}
|
}
|
||||||
completion(strongSelf.image)
|
completion(strongSelf.image)
|
||||||
} else {
|
} else {
|
||||||
completion(nil)
|
completion(strongSelf.image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -595,7 +602,6 @@ private func render(context: CIContext, width: Int, height: Int, bytesPerRow: In
|
|||||||
let calculatedBytesPerRow = (4 * Int(width) + 31) & (~31)
|
let calculatedBytesPerRow = (4 * Int(width) + 31) & (~31)
|
||||||
//assert(bytesPerRow == calculatedBytesPerRow)
|
//assert(bytesPerRow == calculatedBytesPerRow)
|
||||||
|
|
||||||
|
|
||||||
CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
|
CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
|
||||||
let dest = CVPixelBufferGetBaseAddress(pixelBuffer)
|
let dest = CVPixelBufferGetBaseAddress(pixelBuffer)
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@ final class MediaEditorRenderer {
|
|||||||
enum Input {
|
enum Input {
|
||||||
case texture(MTLTexture, CMTime, Bool)
|
case texture(MTLTexture, CMTime, Bool)
|
||||||
case videoBuffer(VideoPixelBuffer)
|
case videoBuffer(VideoPixelBuffer)
|
||||||
|
case ciImage(CIImage, CMTime)
|
||||||
|
|
||||||
var timestamp: CMTime {
|
var timestamp: CMTime {
|
||||||
switch self {
|
switch self {
|
||||||
@ -68,6 +69,8 @@ final class MediaEditorRenderer {
|
|||||||
return timestamp
|
return timestamp
|
||||||
case let .videoBuffer(videoBuffer):
|
case let .videoBuffer(videoBuffer):
|
||||||
return videoBuffer.timestamp
|
return videoBuffer.timestamp
|
||||||
|
case let .ciImage(_, timestamp):
|
||||||
|
return timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,6 +83,7 @@ final class MediaEditorRenderer {
|
|||||||
|
|
||||||
private var renderPasses: [RenderPass] = []
|
private var renderPasses: [RenderPass] = []
|
||||||
|
|
||||||
|
private let ciInputPass = CIInputPass()
|
||||||
private let mainVideoInputPass = VideoInputPass()
|
private let mainVideoInputPass = VideoInputPass()
|
||||||
private let additionalVideoInputPass = VideoInputPass()
|
private let additionalVideoInputPass = VideoInputPass()
|
||||||
let videoFinishPass = VideoFinishPass()
|
let videoFinishPass = VideoFinishPass()
|
||||||
@ -150,6 +154,7 @@ final class MediaEditorRenderer {
|
|||||||
|
|
||||||
self.commandQueue = device.makeCommandQueue()
|
self.commandQueue = device.makeCommandQueue()
|
||||||
self.commandQueue?.label = "Media Editor Command Queue"
|
self.commandQueue?.label = "Media Editor Command Queue"
|
||||||
|
self.ciInputPass.setup(device: device, library: library)
|
||||||
self.mainVideoInputPass.setup(device: device, library: library)
|
self.mainVideoInputPass.setup(device: device, library: library)
|
||||||
self.additionalVideoInputPass.setup(device: device, library: library)
|
self.additionalVideoInputPass.setup(device: device, library: library)
|
||||||
self.videoFinishPass.setup(device: device, library: library)
|
self.videoFinishPass.setup(device: device, library: library)
|
||||||
@ -190,8 +195,14 @@ final class MediaEditorRenderer {
|
|||||||
case let .texture(texture, _, hasTransparency):
|
case let .texture(texture, _, hasTransparency):
|
||||||
return (texture, hasTransparency)
|
return (texture, hasTransparency)
|
||||||
case let .videoBuffer(videoBuffer):
|
case let .videoBuffer(videoBuffer):
|
||||||
if let buffer = videoInputPass.processPixelBuffer(videoBuffer, textureCache: textureCache, device: device, commandBuffer: commandBuffer) {
|
if let texture = videoInputPass.processPixelBuffer(videoBuffer, textureCache: textureCache, device: device, commandBuffer: commandBuffer) {
|
||||||
return (buffer, false)
|
return (texture, false)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case let .ciImage(image, _):
|
||||||
|
if let texture = self.ciInputPass.processCIImage(image, device: device, commandBuffer: commandBuffer) {
|
||||||
|
return (texture, true)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -48,6 +48,7 @@ public final class MediaEditorVideoExport {
|
|||||||
public enum Subject {
|
public enum Subject {
|
||||||
case image(image: UIImage)
|
case image(image: UIImage)
|
||||||
case video(asset: AVAsset, isStory: Bool)
|
case video(asset: AVAsset, isStory: Bool)
|
||||||
|
case sticker(file: TelegramMediaFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Configuration {
|
public struct Configuration {
|
||||||
@ -199,6 +200,9 @@ public final class MediaEditorVideoExport {
|
|||||||
|
|
||||||
private var audioOutput: AVAssetReaderOutput?
|
private var audioOutput: AVAssetReaderOutput?
|
||||||
|
|
||||||
|
private var stickerEntity: MediaEditorComposerStickerEntity?
|
||||||
|
private let stickerSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
private var writer: MediaEditorVideoExportWriter?
|
private var writer: MediaEditorVideoExportWriter?
|
||||||
private var composer: MediaEditorComposer?
|
private var composer: MediaEditorComposer?
|
||||||
|
|
||||||
@ -218,7 +222,7 @@ public final class MediaEditorVideoExport {
|
|||||||
|
|
||||||
private var startTimestamp = CACurrentMediaTime()
|
private var startTimestamp = CACurrentMediaTime()
|
||||||
|
|
||||||
private let semaphore = DispatchSemaphore(value: 0)
|
private let composerSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
public init(postbox: Postbox, subject: Subject, configuration: Configuration, outputPath: String, textScale: CGFloat = 1.0) {
|
public init(postbox: Postbox, subject: Subject, configuration: Configuration, outputPath: String, textScale: CGFloat = 1.0) {
|
||||||
self.postbox = postbox
|
self.postbox = postbox
|
||||||
@ -249,6 +253,7 @@ public final class MediaEditorVideoExport {
|
|||||||
enum Input {
|
enum Input {
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
case video(AVAsset)
|
case video(AVAsset)
|
||||||
|
case sticker(TelegramMediaFile)
|
||||||
|
|
||||||
var isVideo: Bool {
|
var isVideo: Bool {
|
||||||
if case .video = self {
|
if case .video = self {
|
||||||
@ -283,6 +288,8 @@ public final class MediaEditorVideoExport {
|
|||||||
isStory = isStoryValue
|
isStory = isStoryValue
|
||||||
case let .image(image):
|
case let .image(image):
|
||||||
mainInput = .image(image)
|
mainInput = .image(image)
|
||||||
|
case let .sticker(file):
|
||||||
|
mainInput = .sticker(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
let duration: CMTime
|
let duration: CMTime
|
||||||
@ -477,6 +484,10 @@ public final class MediaEditorVideoExport {
|
|||||||
writer.setup(configuration: self.configuration, outputPath: self.outputPath)
|
writer.setup(configuration: self.configuration, outputPath: self.outputPath)
|
||||||
self.setupComposer()
|
self.setupComposer()
|
||||||
|
|
||||||
|
if case let .sticker(file) = main, let composer = self.composer {
|
||||||
|
self.stickerEntity = MediaEditorComposerStickerEntity(postbox: self.postbox, content: .file(file), position: .zero, scale: 1.0, rotation: 0.0, baseSize: CGSize(width: 512.0, height: 512.0), mirrored: false, colorSpace: composer.colorSpace, tintColor: nil, isStatic: false, highRes: true)
|
||||||
|
}
|
||||||
|
|
||||||
if let reader {
|
if let reader {
|
||||||
let colorProperties: [String: Any] = [
|
let colorProperties: [String: Any] = [
|
||||||
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
|
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
|
||||||
@ -657,6 +668,24 @@ public final class MediaEditorVideoExport {
|
|||||||
writer.markVideoAsFinished()
|
writer.markVideoAsFinished()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let stickerEntity = self.stickerEntity, let ciContext = composer.ciContext {
|
||||||
|
let imageArguments = self.imageArguments
|
||||||
|
stickerEntity.image(for: timestamp, frameRate: Float(imageArguments?.frameRate ?? 30.0), context: ciContext, completion: { image in
|
||||||
|
if let image {
|
||||||
|
mainInput = .ciImage(image, imageArguments?.position ?? .zero)
|
||||||
|
}
|
||||||
|
self.stickerSemaphore.signal()
|
||||||
|
})
|
||||||
|
self.stickerSemaphore.wait()
|
||||||
|
|
||||||
|
if !updatedProgress, let imageArguments = self.imageArguments, let duration = self.durationValue {
|
||||||
|
let progress = imageArguments.position.seconds / duration.seconds
|
||||||
|
self.statusValue = .progress(Float(progress))
|
||||||
|
updatedProgress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
composer.process(
|
composer.process(
|
||||||
main: mainInput!,
|
main: mainInput!,
|
||||||
additional: additionalInput,
|
additional: additionalInput,
|
||||||
@ -671,10 +700,10 @@ public final class MediaEditorVideoExport {
|
|||||||
} else {
|
} else {
|
||||||
appendFailed = true
|
appendFailed = true
|
||||||
}
|
}
|
||||||
self.semaphore.signal()
|
self.composerSemaphore.signal()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.semaphore.wait()
|
self.composerSemaphore.wait()
|
||||||
|
|
||||||
if let imageArguments = self.imageArguments, let duration = self.durationValue {
|
if let imageArguments = self.imageArguments, let duration = self.durationValue {
|
||||||
let position = imageArguments.position + CMTime(value: 1, timescale: Int32(imageArguments.frameRate))
|
let position = imageArguments.position + CMTime(value: 1, timescale: Int32(imageArguments.frameRate))
|
||||||
@ -736,8 +765,13 @@ public final class MediaEditorVideoExport {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .image = self.subject, self.additionalVideoOutput == nil {
|
if self.additionalVideoOutput == nil {
|
||||||
|
switch self.subject {
|
||||||
|
case .image, .sticker:
|
||||||
self.imageArguments = (Double(self.configuration.frameRate), CMTime(value: 0, timescale: Int32(self.configuration.frameRate)))
|
self.imageArguments = (Double(self.configuration.frameRate), CMTime(value: 0, timescale: Int32(self.configuration.frameRate)))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.internalStatus = .exporting
|
self.internalStatus = .exporting
|
||||||
|
@ -3,11 +3,13 @@ import AVFoundation
|
|||||||
import Metal
|
import Metal
|
||||||
import MetalKit
|
import MetalKit
|
||||||
import ImageTransparency
|
import ImageTransparency
|
||||||
|
import SwiftSignalKit
|
||||||
|
|
||||||
final class UniversalTextureSource: TextureSource {
|
final class UniversalTextureSource: TextureSource {
|
||||||
enum Input {
|
enum Input {
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
case video(AVPlayerItem)
|
case video(AVPlayerItem)
|
||||||
|
case entity(MediaEditorComposerEntity)
|
||||||
|
|
||||||
fileprivate func createContext(renderTarget: RenderTarget, queue: DispatchQueue, additional: Bool) -> InputContext {
|
fileprivate func createContext(renderTarget: RenderTarget, queue: DispatchQueue, additional: Bool) -> InputContext {
|
||||||
switch self {
|
switch self {
|
||||||
@ -15,6 +17,8 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
return ImageInputContext(input: self, renderTarget: renderTarget, queue: queue)
|
return ImageInputContext(input: self, renderTarget: renderTarget, queue: queue)
|
||||||
case .video:
|
case .video:
|
||||||
return VideoInputContext(input: self, renderTarget: renderTarget, queue: queue, additional: additional)
|
return VideoInputContext(input: self, renderTarget: renderTarget, queue: queue, additional: additional)
|
||||||
|
case .entity:
|
||||||
|
return EntityInputContext(input: self, renderTarget: renderTarget, queue: queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,9 +80,15 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var previousAdditionalOutput: MediaEditorRenderer.Input?
|
private var previousAdditionalOutput: MediaEditorRenderer.Input?
|
||||||
|
private var readyForMoreData = Atomic<Bool>(value: true)
|
||||||
private func update(forced: Bool) {
|
private func update(forced: Bool) {
|
||||||
let time = CACurrentMediaTime()
|
let time = CACurrentMediaTime()
|
||||||
|
|
||||||
|
var fps: Int = 60
|
||||||
|
if self.mainInputContext?.useAsyncOutput == true {
|
||||||
|
fps = 30
|
||||||
|
}
|
||||||
|
|
||||||
let needsDisplayLink = (self.mainInputContext?.needsDisplayLink ?? false) || (self.additionalInputContext?.needsDisplayLink ?? false)
|
let needsDisplayLink = (self.mainInputContext?.needsDisplayLink ?? false) || (self.additionalInputContext?.needsDisplayLink ?? false)
|
||||||
if needsDisplayLink {
|
if needsDisplayLink {
|
||||||
if self.displayLink == nil {
|
if self.displayLink == nil {
|
||||||
@ -87,7 +97,7 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
self.update(forced: self.forceUpdates)
|
self.update(forced: self.forceUpdates)
|
||||||
}
|
}
|
||||||
}), selector: #selector(DisplayLinkTarget.handleDisplayLinkUpdate(sender:)))
|
}), selector: #selector(DisplayLinkTarget.handleDisplayLinkUpdate(sender:)))
|
||||||
displayLink.preferredFramesPerSecond = 60
|
displayLink.preferredFramesPerSecond = fps
|
||||||
displayLink.add(to: .main, forMode: .common)
|
displayLink.add(to: .main, forMode: .common)
|
||||||
self.displayLink = displayLink
|
self.displayLink = displayLink
|
||||||
}
|
}
|
||||||
@ -102,6 +112,21 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let mainInputContext = self.mainInputContext, mainInputContext.useAsyncOutput {
|
||||||
|
guard self.readyForMoreData.with({ $0 }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let _ = self.readyForMoreData.swap(false)
|
||||||
|
mainInputContext.asyncOutput(time: time, completion: { [weak self] main in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let main {
|
||||||
|
self.output?.consume(main: main, additional: nil, render: true)
|
||||||
|
}
|
||||||
|
let _ = self.readyForMoreData.swap(true)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
let main = self.mainInputContext?.output(time: time)
|
let main = self.mainInputContext?.output(time: time)
|
||||||
var additional = self.additionalInputContext?.output(time: time)
|
var additional = self.additionalInputContext?.output(time: time)
|
||||||
if let additional {
|
if let additional {
|
||||||
@ -109,13 +134,12 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
} else if self.additionalInputContext != nil {
|
} else if self.additionalInputContext != nil {
|
||||||
additional = self.previousAdditionalOutput
|
additional = self.previousAdditionalOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let main else {
|
guard let main else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.output?.consume(main: main, additional: additional, render: true)
|
self.output?.consume(main: main, additional: additional, render: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func connect(to consumer: MediaEditorRenderer) {
|
func connect(to consumer: MediaEditorRenderer) {
|
||||||
self.output = consumer
|
self.output = consumer
|
||||||
@ -138,18 +162,31 @@ final class UniversalTextureSource: TextureSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private protocol InputContext {
|
protocol InputContext {
|
||||||
typealias Input = UniversalTextureSource.Input
|
typealias Input = UniversalTextureSource.Input
|
||||||
typealias Output = MediaEditorRenderer.Input
|
typealias Output = MediaEditorRenderer.Input
|
||||||
|
|
||||||
var input: Input { get }
|
var input: Input { get }
|
||||||
|
|
||||||
|
var useAsyncOutput: Bool { get }
|
||||||
func output(time: Double) -> Output?
|
func output(time: Double) -> Output?
|
||||||
|
func asyncOutput(time: Double, completion: @escaping (Output?) -> Void)
|
||||||
|
|
||||||
var needsDisplayLink: Bool { get }
|
var needsDisplayLink: Bool { get }
|
||||||
|
|
||||||
func invalidate()
|
func invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension InputContext {
|
||||||
|
var useAsyncOutput: Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func asyncOutput(time: Double, completion: @escaping (Output?) -> Void) {
|
||||||
|
completion(self.output(time: time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class ImageInputContext: InputContext {
|
private class ImageInputContext: InputContext {
|
||||||
fileprivate var input: Input
|
fileprivate var input: Input
|
||||||
private var texture: MTLTexture?
|
private var texture: MTLTexture?
|
||||||
@ -248,3 +285,59 @@ private class VideoInputContext: NSObject, InputContext, AVPlayerItemOutputPullD
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class EntityInputContext: NSObject, InputContext, AVPlayerItemOutputPullDelegate {
|
||||||
|
internal var input: Input
|
||||||
|
private var textureRotation: TextureRotation = .rotate0Degrees
|
||||||
|
|
||||||
|
var entity: MediaEditorComposerEntity {
|
||||||
|
guard case let .entity(entity) = self.input else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
private let ciContext: CIContext
|
||||||
|
private let startTime: Double
|
||||||
|
|
||||||
|
init(input: Input, renderTarget: RenderTarget, queue: DispatchQueue) {
|
||||||
|
guard case .entity = input else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
self.input = input
|
||||||
|
self.ciContext = CIContext(options: [.workingColorSpace : CGColorSpaceCreateDeviceRGB()])
|
||||||
|
self.startTime = CACurrentMediaTime()
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
self.textureRotation = .rotate0Degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
func output(time: Double) -> Output? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asyncOutput(time: Double, completion: @escaping (Output?) -> Void) {
|
||||||
|
let deltaTime = max(0.0, time - self.startTime)
|
||||||
|
let timestamp = CMTime(seconds: deltaTime, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||||
|
self.entity.image(for: timestamp, frameRate: 30, context: self.ciContext, completion: { image in
|
||||||
|
Queue.mainQueue().async {
|
||||||
|
completion(image.flatMap { .ciImage($0, timestamp) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsDisplayLink: Bool {
|
||||||
|
if let entity = self.entity as? MediaEditorComposerStickerEntity, entity.isAnimated {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var useAsyncOutput: Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Metal
|
import Metal
|
||||||
import MetalKit
|
import MetalKit
|
||||||
|
import CoreImage
|
||||||
|
|
||||||
final class VideoInputPass: DefaultRenderPass {
|
final class VideoInputPass: DefaultRenderPass {
|
||||||
private var cachedTexture: MTLTexture?
|
private var cachedTexture: MTLTexture?
|
||||||
@ -84,3 +85,44 @@ final class VideoInputPass: DefaultRenderPass {
|
|||||||
return self.cachedTexture
|
return self.cachedTexture
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class CIInputPass: RenderPass {
|
||||||
|
private var context: CIContext?
|
||||||
|
|
||||||
|
func setup(device: MTLDevice, library: MTLLibrary) {
|
||||||
|
self.context = CIContext(mtlDevice: device, options: [.workingColorSpace : CGColorSpaceCreateDeviceRGB()])
|
||||||
|
}
|
||||||
|
|
||||||
|
func process(input: MTLTexture, device: MTLDevice, commandBuffer: MTLCommandBuffer) -> MTLTexture? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outputTexture: MTLTexture?
|
||||||
|
|
||||||
|
func processCIImage(_ ciImage: CIImage, device: MTLDevice, commandBuffer: MTLCommandBuffer) -> MTLTexture? {
|
||||||
|
if self.outputTexture == nil {
|
||||||
|
let textureDescriptor = MTLTextureDescriptor()
|
||||||
|
textureDescriptor.textureType = .type2D
|
||||||
|
textureDescriptor.width = Int(ciImage.extent.width)
|
||||||
|
textureDescriptor.height = Int(ciImage.extent.height)
|
||||||
|
textureDescriptor.pixelFormat = .bgra8Unorm
|
||||||
|
textureDescriptor.storageMode = .private
|
||||||
|
textureDescriptor.usage = [.shaderRead, .shaderWrite, .renderTarget]
|
||||||
|
guard let texture = device.makeTexture(descriptor: textureDescriptor) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.outputTexture = texture
|
||||||
|
texture.label = "outlineOutputTexture"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let outputTexture = self.outputTexture, let context = self.context else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let transformedImage = ciImage.transformed(by: CGAffineTransformMakeScale(1.0, -1.0).translatedBy(x: 0.0, y: -ciImage.extent.height))
|
||||||
|
let renderDestination = CIRenderDestination(mtlTexture: outputTexture, commandBuffer: commandBuffer)
|
||||||
|
_ = try? context.startTask(toRender: transformedImage, to: renderDestination)
|
||||||
|
|
||||||
|
return outputTexture
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2936,6 +2936,8 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
mediaEntity.position = mediaEntity.position.offsetBy(dx: initialValues.cropOffset.x, dy: initialValues.cropOffset.y)
|
mediaEntity.position = mediaEntity.position.offsetBy(dx: initialValues.cropOffset.x, dy: initialValues.cropOffset.y)
|
||||||
mediaEntity.rotation = mediaEntity.rotation + initialValues.cropRotation
|
mediaEntity.rotation = mediaEntity.rotation + initialValues.cropRotation
|
||||||
mediaEntity.scale = mediaEntity.scale * initialValues.cropScale
|
mediaEntity.scale = mediaEntity.scale * initialValues.cropScale
|
||||||
|
} else if case .sticker = subject {
|
||||||
|
mediaEntity.scale = mediaEntity.scale * 0.97
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3087,13 +3089,8 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
self.readyValue.set(.single(true))
|
self.readyValue.set(.single(true))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else if case let .sticker(sticker, emoji) = effectiveSubject {
|
} else if case let .sticker(_, emoji) = effectiveSubject {
|
||||||
controller.stickerSelectedEmoji = emoji
|
controller.stickerSelectedEmoji = emoji
|
||||||
let stickerEntity = DrawingStickerEntity(content: .file(.standalone(media: sticker), .sticker))
|
|
||||||
stickerEntity.referenceDrawingSize = storyDimensions
|
|
||||||
stickerEntity.scale = 4.0 * 0.97
|
|
||||||
stickerEntity.position = CGPoint(x: storyDimensions.width / 2.0, y: storyDimensions.height / 2.0)
|
|
||||||
self.entitiesView.add(stickerEntity, announce: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.gradientColorsDisposable = mediaEditor.gradientColors.start(next: { [weak self] colors in
|
self.gradientColorsDisposable = mediaEditor.gradientColors.start(next: { [weak self] colors in
|
||||||
@ -6394,12 +6391,25 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
private func effectiveStickerEmoji() -> [String] {
|
private func effectiveStickerEmoji() -> [String] {
|
||||||
let filtered = self.stickerSelectedEmoji.filter { !$0.isEmpty }
|
let filtered = self.stickerSelectedEmoji.filter { !$0.isEmpty }
|
||||||
guard !filtered.isEmpty else {
|
guard !filtered.isEmpty else {
|
||||||
|
for entity in self.node.entitiesView.entities {
|
||||||
|
if let stickerEntity = entity as? DrawingStickerEntity, case let .file(file, _) = stickerEntity.content {
|
||||||
|
for attribute in file.media.attributes {
|
||||||
|
if case let .Sticker(displayText, _, _) = attribute {
|
||||||
|
return [displayText]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return ["🫥"]
|
return ["🫥"]
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preferredStickerDuration() -> Double {
|
private func preferredStickerDuration() -> Double {
|
||||||
|
if let duration = self.node.mediaEditor?.duration, duration > 0.0 {
|
||||||
|
return min(3.0, duration)
|
||||||
|
}
|
||||||
var duration: Double = 3.0
|
var duration: Double = 3.0
|
||||||
var stickerDurations: [Double] = []
|
var stickerDurations: [Double] = []
|
||||||
self.node.entitiesView.eachView { entityView in
|
self.node.entitiesView.eachView { entityView in
|
||||||
@ -6412,7 +6422,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
if !stickerDurations.isEmpty {
|
if !stickerDurations.isEmpty {
|
||||||
duration = stickerDurations.max() ?? 3.0
|
duration = stickerDurations.max() ?? 3.0
|
||||||
}
|
}
|
||||||
return duration
|
return min(3.0, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
private weak var stickerResultController: PeekController?
|
private weak var stickerResultController: PeekController?
|
||||||
@ -6429,13 +6439,15 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
if mediaEditor.resultIsVideo {
|
if mediaEditor.resultIsVideo {
|
||||||
isVideo = true
|
isVideo = true
|
||||||
}
|
}
|
||||||
|
let imagesReady = ValuePromise<Bool>(false, ignoreRepeated: true)
|
||||||
Queue.concurrentDefaultQueue().async {
|
Queue.concurrentDefaultQueue().async {
|
||||||
if !isVideo, let data = try? WebP.convert(toWebP: image, quality: 97.0) {
|
if !isVideo, let data = try? WebP.convert(toWebP: image, quality: 97.0) {
|
||||||
self.context.account.postbox.mediaBox.storeResourceData(isVideo ? thumbnailResource.id : resource.id, data: data)
|
self.context.account.postbox.mediaBox.storeResourceData(isVideo ? thumbnailResource.id : resource.id, data: data, synchronous: true)
|
||||||
}
|
}
|
||||||
if let thumbnailImage = generateScaledImage(image: image, size: CGSize(width: 320.0, height: 320.0), opaque: false, scale: 1.0), let data = try? WebP.convert(toWebP: thumbnailImage, quality: 90.0) {
|
if let thumbnailImage = generateScaledImage(image: image, size: CGSize(width: 320.0, height: 320.0), opaque: false, scale: 1.0), let data = try? WebP.convert(toWebP: thumbnailImage, quality: 90.0) {
|
||||||
self.context.account.postbox.mediaBox.storeResourceData(thumbnailResource.id, data: data)
|
self.context.account.postbox.mediaBox.storeResourceData(thumbnailResource.id, data: data, synchronous: true)
|
||||||
}
|
}
|
||||||
|
imagesReady.set(true)
|
||||||
}
|
}
|
||||||
var file = stickerFile(resource: resource, thumbnailResource: thumbnailResource, size: Int64(0), dimensions: PixelDimensions(image.size), duration: self.preferredStickerDuration(), isVideo: isVideo)
|
var file = stickerFile(resource: resource, thumbnailResource: thumbnailResource, size: Int64(0), dimensions: PixelDimensions(image.size), duration: self.preferredStickerDuration(), isVideo: isVideo)
|
||||||
|
|
||||||
@ -6448,6 +6460,13 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = (imagesReady.get()
|
||||||
|
|> filter { $0 }
|
||||||
|
|> take(1)
|
||||||
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if isVideo {
|
if isVideo {
|
||||||
self.uploadSticker(file, action: .send)
|
self.uploadSticker(file, action: .send)
|
||||||
} else {
|
} else {
|
||||||
@ -6468,15 +6487,24 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
f(.default)
|
f(.default)
|
||||||
})))
|
})))
|
||||||
menuItems.append(.action(ContextMenuActionItem(text: presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
|
menuItems.append(.action(ContextMenuActionItem(text: presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
|
||||||
f(.default)
|
f(.default)
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let _ = (imagesReady.get()
|
||||||
|
|> filter { $0 }
|
||||||
|
|> take(1)
|
||||||
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.uploadSticker(file, action: .addToFavorites)
|
self.uploadSticker(file, action: .addToFavorites)
|
||||||
|
})
|
||||||
})))
|
})))
|
||||||
menuItems.append(.action(ContextMenuActionItem(text: "Add to Sticker Set", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddSticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
|
menuItems.append(.action(ContextMenuActionItem(text: "Add to Sticker Set", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddSticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@ -6518,7 +6546,15 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
self.present(controller, in: .window(.root))
|
self.present(controller, in: .window(.root))
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
|
let _ = (imagesReady.get()
|
||||||
|
|> filter { $0 }
|
||||||
|
|> take(1)
|
||||||
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.uploadSticker(file, action: .addToStickerPack(pack: .id(id: pack.id.id, accessHash: pack.accessHash), title: pack.title))
|
self.uploadSticker(file, action: .addToStickerPack(pack: .id(id: pack.id.id, accessHash: pack.accessHash), title: pack.title))
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}), false))
|
}), false))
|
||||||
@ -6554,8 +6590,15 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
file = sticker
|
file = sticker
|
||||||
action = .update
|
action = .update
|
||||||
}
|
}
|
||||||
|
let _ = (imagesReady.get()
|
||||||
|
|> filter { $0 }
|
||||||
|
|> take(1)
|
||||||
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.uploadSticker(file, action: action)
|
self.uploadSticker(file, action: action)
|
||||||
|
})
|
||||||
})))
|
})))
|
||||||
case .addingToPack:
|
case .addingToPack:
|
||||||
menuItems.append(.action(ContextMenuActionItem(text: "Add to Sticker Set", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddSticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
|
menuItems.append(.action(ContextMenuActionItem(text: "Add to Sticker Set", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddSticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
|
||||||
@ -6563,7 +6606,16 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
f(.default)
|
f(.default)
|
||||||
|
|
||||||
|
let _ = (imagesReady.get()
|
||||||
|
|> filter { $0 }
|
||||||
|
|> take(1)
|
||||||
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.uploadSticker(file, action: .upload)
|
self.uploadSticker(file, action: .upload)
|
||||||
|
})
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7028,11 +7080,8 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
return image.flatMap({ .single(.image(image: $0)) }) ?? .complete()
|
return image.flatMap({ .single(.image(image: $0)) }) ?? .complete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .sticker:
|
case let .sticker(file, _):
|
||||||
let image = generateImage(CGSize(width: 1080, height: 1920), contextGenerator: { size, context in
|
exportSubject = .single(.sticker(file: file))
|
||||||
context.clear(CGRect(origin: .zero, size: size))
|
|
||||||
}, opaque: false, scale: 1.0)!
|
|
||||||
exportSubject = .single(.image(image: image))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = exportSubject.start(next: { [weak self] exportSubject in
|
let _ = exportSubject.start(next: { [weak self] exportSubject in
|
||||||
@ -7046,8 +7095,11 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
|
|||||||
}
|
}
|
||||||
if isSticker {
|
if isSticker {
|
||||||
duration = self.preferredStickerDuration()
|
duration = self.preferredStickerDuration()
|
||||||
|
if case .sticker = subject {
|
||||||
|
} else {
|
||||||
values = values.withUpdatedMaskDrawing(maskDrawing: self.node.stickerMaskDrawingView?.drawingImage)
|
values = values.withUpdatedMaskDrawing(maskDrawing: self.node.stickerMaskDrawingView?.drawingImage)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let configuration = recommendedVideoExportConfiguration(values: values, duration: duration, forceFullHd: true, frameRate: 60.0, isSticker: isSticker)
|
let configuration = recommendedVideoExportConfiguration(values: values, duration: duration, forceFullHd: true, frameRate: 60.0, isSticker: isSticker)
|
||||||
let outputPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).\(fileExtension)"
|
let outputPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).\(fileExtension)"
|
||||||
let videoExport = MediaEditorVideoExport(postbox: self.context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: outputPath, textScale: 2.0)
|
let videoExport = MediaEditorVideoExport(postbox: self.context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: outputPath, textScale: 2.0)
|
||||||
|
@ -89,7 +89,7 @@ final class StickerCutoutOutlineView: UIView {
|
|||||||
lineEmitterCell.contents = UIImage(named: "Media Editor/ParticleDot")?.cgImage
|
lineEmitterCell.contents = UIImage(named: "Media Editor/ParticleDot")?.cgImage
|
||||||
lineEmitterCell.lifetime = 2.2
|
lineEmitterCell.lifetime = 2.2
|
||||||
lineEmitterCell.birthRate = 1700
|
lineEmitterCell.birthRate = 1700
|
||||||
lineEmitterCell.scale = 0.18
|
lineEmitterCell.scale = 0.185
|
||||||
lineEmitterCell.alphaSpeed = -0.4
|
lineEmitterCell.alphaSpeed = -0.4
|
||||||
|
|
||||||
self.outlineLayer.emitterCells = [lineEmitterCell]
|
self.outlineLayer.emitterCells = [lineEmitterCell]
|
||||||
@ -157,17 +157,14 @@ final class StickerCutoutOutlineView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func getPathFromMaskImage(_ image: CIImage, size: CGSize, values: MediaEditorValues) -> BezierPath? {
|
private func getPathFromMaskImage(_ image: CIImage, size: CGSize, values: MediaEditorValues) -> BezierPath? {
|
||||||
// let edges = image.applyingFilter("CILineOverlay", parameters: ["inputEdgeIntensity": 0.1])
|
let extendedImage = image.applyingFilter("CIMorphologyMaximum", parameters: ["inputRadius": 3.0])
|
||||||
|
guard let pixelBuffer = getEdgesBitmap(extendedImage) else {
|
||||||
guard let pixelBuffer = getEdgesBitmap(image) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let minSide = min(size.width, size.height)
|
let minSide = min(size.width, size.height)
|
||||||
let scaledImageSize = image.extent.size.aspectFilled(CGSize(width: minSide, height: minSide))
|
let scaledImageSize = image.extent.size.aspectFilled(CGSize(width: minSide, height: minSide))
|
||||||
let contourImageSize = image.extent.size.aspectFilled(CGSize(width: 256.0, height: 256.0))
|
let contourImageSize = image.extent.size.aspectFilled(CGSize(width: 256.0, height: 256.0))
|
||||||
|
|
||||||
// var contour = findContours(pixelBuffer: pixelBuffer)
|
|
||||||
|
|
||||||
var contour = findEdgePoints(in: pixelBuffer)
|
var contour = findEdgePoints(in: pixelBuffer)
|
||||||
guard !contour.isEmpty else {
|
guard !contour.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
@ -285,97 +282,6 @@ outerLoop: for y in 0..<height {
|
|||||||
return Array(edgePath.map { $0.cgPoint })
|
return Array(edgePath.map { $0.cgPoint })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findContours(pixelBuffer: CVPixelBuffer) -> [CGPoint] {
|
|
||||||
struct Point: Hashable {
|
|
||||||
let x: Int
|
|
||||||
let y: Int
|
|
||||||
|
|
||||||
var cgPoint: CGPoint {
|
|
||||||
return CGPoint(x: x, y: y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var contours = [[Point]]()
|
|
||||||
|
|
||||||
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
|
||||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
|
|
||||||
|
|
||||||
let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer)
|
|
||||||
let width = CVPixelBufferGetWidth(pixelBuffer)
|
|
||||||
let height = CVPixelBufferGetHeight(pixelBuffer)
|
|
||||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
|
||||||
|
|
||||||
var visited: [Point: Bool] = [:]
|
|
||||||
func markVisited(_ point: Point) {
|
|
||||||
visited[point] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPixelIntensity(_ point: Point) -> UInt8 {
|
|
||||||
let pixelOffset = point.y * bytesPerRow + point.x
|
|
||||||
let pixelPtr = baseAddress?.advanced(by: pixelOffset)
|
|
||||||
return pixelPtr?.load(as: UInt8.self) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBlackPixel(_ point: Point) -> Bool {
|
|
||||||
if point.x >= 0 && point.x < width && point.y >= 0 && point.y < height {
|
|
||||||
let value = getPixelIntensity(point)
|
|
||||||
return value < 225
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func traceContour(startPoint: Point) -> [Point] {
|
|
||||||
var contour = [startPoint]
|
|
||||||
var currentPoint = startPoint
|
|
||||||
var previousDirection = 7
|
|
||||||
|
|
||||||
let dx = [1, 1, 0, -1, -1, -1, 0, 1]
|
|
||||||
let dy = [0, 1, 1, 1, 0, -1, -1, -1]
|
|
||||||
|
|
||||||
repeat {
|
|
||||||
var found = false
|
|
||||||
for i in 0 ..< 8 {
|
|
||||||
let direction = (previousDirection + i) % 8
|
|
||||||
let newX = currentPoint.x + dx[direction]
|
|
||||||
let newY = currentPoint.y + dy[direction]
|
|
||||||
let newPoint = Point(x: newX, y: newY)
|
|
||||||
|
|
||||||
if isBlackPixel(newPoint) && !(visited[newPoint] == true) {
|
|
||||||
contour.append(newPoint)
|
|
||||||
previousDirection = (direction + 5) % 8
|
|
||||||
currentPoint = newPoint
|
|
||||||
found = true
|
|
||||||
markVisited(newPoint)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} while currentPoint != startPoint
|
|
||||||
|
|
||||||
return contour
|
|
||||||
}
|
|
||||||
|
|
||||||
for y in 0 ..< height {
|
|
||||||
for x in 0 ..< width {
|
|
||||||
let point = Point(x: x, y: y)
|
|
||||||
if visited[point] == true {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isBlackPixel(point) {
|
|
||||||
let contour = traceContour(startPoint: point)
|
|
||||||
if contour.count > 25 {
|
|
||||||
contours.append(contour)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (contours.sorted(by: { lhs, rhs in lhs.count > rhs.count }).first ?? []).map { $0.cgPoint }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getEdgesBitmap(_ ciImage: CIImage) -> CVPixelBuffer? {
|
private func getEdgesBitmap(_ ciImage: CIImage) -> CVPixelBuffer? {
|
||||||
let context = CIContext(options: nil)
|
let context = CIContext(options: nil)
|
||||||
guard let contourCgImage = context.createCGImage(ciImage, from: ciImage.extent) else {
|
guard let contourCgImage = context.createCGImage(ciImage, from: ciImage.extent) else {
|
||||||
|
@ -35,7 +35,7 @@ public func transformOutgoingMessageMedia(postbox: Postbox, network: Network, me
|
|||||||
return result
|
return result
|
||||||
|> mapToSignal { data -> Signal<AnyMediaReference?, NoError> in
|
|> mapToSignal { data -> Signal<AnyMediaReference?, NoError> in
|
||||||
if data.complete {
|
if data.complete {
|
||||||
if file.mimeType.hasPrefix("image/") {
|
if file.mimeType.hasPrefix("image/") && !file.mimeType.hasSuffix("/webp") {
|
||||||
return Signal { subscriber in
|
return Signal { subscriber in
|
||||||
if let fullSizeData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
|
if let fullSizeData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
|
||||||
let options = NSMutableDictionary()
|
let options = NSMutableDictionary()
|
||||||
@ -88,7 +88,7 @@ public func transformOutgoingMessageMedia(postbox: Postbox, network: Network, me
|
|||||||
|
|
||||||
return EmptyDisposable
|
return EmptyDisposable
|
||||||
} |> runOn(opportunistic ? Queue.mainQueue() : Queue.concurrentDefaultQueue())
|
} |> runOn(opportunistic ? Queue.mainQueue() : Queue.concurrentDefaultQueue())
|
||||||
} else if file.mimeType.hasPrefix("video/") {
|
} else if file.mimeType.hasPrefix("video/") && !file.mimeType.hasSuffix("/webm") {
|
||||||
return Signal { subscriber in
|
return Signal { subscriber in
|
||||||
if let scaledImage = generateVideoFirstFrame(data.path, maxDimensions: CGSize(width: 320.0, height: 320.0)), let thumbnailData = scaledImage.jpegData(compressionQuality: 0.6) {
|
if let scaledImage = generateVideoFirstFrame(data.path, maxDimensions: CGSize(width: 320.0, height: 320.0)), let thumbnailData = scaledImage.jpegData(compressionQuality: 0.6) {
|
||||||
let thumbnailResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
|
let thumbnailResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user