From 480fa08e4541d482ff9ed1bdac65bd140deacbe1 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 Jun 2023 02:48:17 +0400 Subject: [PATCH 1/3] Camera and editor improvements --- .../Sources/VideoStickerFrameSource.swift | 16 +- .../Sources/AttachmentContainer.swift | 1 + .../Navigation/NavigationContainer.swift | 15 +- .../Sources/MediaEditorComposer.swift | 299 ---------------- .../Sources/MediaEditorComposerEntity.swift | 329 ++++++++++++++++++ 5 files changed, 350 insertions(+), 310 deletions(-) create mode 100644 submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift diff --git a/submodules/AnimatedStickerNode/Sources/VideoStickerFrameSource.swift b/submodules/AnimatedStickerNode/Sources/VideoStickerFrameSource.swift index d599a9bb3a..df4c44a6fb 100644 --- a/submodules/AnimatedStickerNode/Sources/VideoStickerFrameSource.swift +++ b/submodules/AnimatedStickerNode/Sources/VideoStickerFrameSource.swift @@ -277,7 +277,7 @@ public func makeVideoStickerDirectFrameSource(queue: Queue, path: String, width: return VideoStickerDirectFrameSource(queue: queue, path: path, width: width, height: height, cachePathPrefix: cachePathPrefix, unpremultiplyAlpha: unpremultiplyAlpha) } -final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { +public final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { private let queue: Queue private let path: String private let width: Int @@ -285,13 +285,13 @@ final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { private let cache: VideoStickerFrameSourceCache? private let image: UIImage? private let bytesPerRow: Int - var frameCount: Int - let frameRate: Int + public var frameCount: Int + public let frameRate: Int fileprivate var currentFrame: Int private let source: SoftwareVideoSource? - var frameIndex: Int { + public var frameIndex: Int { if self.frameCount == 0 { return 0 } else { @@ -299,7 +299,7 @@ final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { } } - init?(queue: Queue, path: String, width: Int, height: Int, cachePathPrefix: String?, unpremultiplyAlpha: Bool = true) { + public init?(queue: Queue, path: String, width: Int, height: Int, cachePathPrefix: String?, unpremultiplyAlpha: Bool = true) { self.queue = queue self.path = path self.width = width @@ -334,7 +334,7 @@ final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { assert(self.queue.isCurrent()) } - func takeFrame(draw: Bool) -> AnimatedStickerFrame? { + public func takeFrame(draw: Bool) -> AnimatedStickerFrame? { let frameIndex: Int if self.frameCount > 0 { frameIndex = self.currentFrame % self.frameCount @@ -415,11 +415,11 @@ final class VideoStickerDirectFrameSource: AnimatedStickerFrameSource { } } - func skipToEnd() { + public func skipToEnd() { self.currentFrame = self.frameCount - 1 } - func skipToFrameIndex(_ index: Int) { + public func skipToFrameIndex(_ index: Int) { self.currentFrame = index } } diff --git a/submodules/AttachmentUI/Sources/AttachmentContainer.swift b/submodules/AttachmentUI/Sources/AttachmentContainer.swift index 85916b1fc1..7d64e0d889 100644 --- a/submodules/AttachmentUI/Sources/AttachmentContainer.swift +++ b/submodules/AttachmentUI/Sources/AttachmentContainer.swift @@ -80,6 +80,7 @@ final class AttachmentContainer: ASDisplayNode, UIGestureRecognizerDelegate { }) self.container.clipsToBounds = true self.container.overflowInset = overflowInset + self.container.shouldAnimateDisappearance = true super.init() diff --git a/submodules/Display/Source/Navigation/NavigationContainer.swift b/submodules/Display/Source/Navigation/NavigationContainer.swift index 08d9e5a25b..32c5ab81a3 100644 --- a/submodules/Display/Source/Navigation/NavigationContainer.swift +++ b/submodules/Display/Source/Navigation/NavigationContainer.swift @@ -439,6 +439,8 @@ public final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelega } } + public var shouldAnimateDisappearance: Bool = false + private func topTransition(from fromValue: Child?, to toValue: Child?, transitionType: PendingChild.TransitionType, layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { if case .animated = transition, let fromValue = fromValue, let toValue = toValue { if let currentTransition = self.state.transition { @@ -501,9 +503,16 @@ public final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelega strongSelf.keyboardViewManager?.dismissEditingWithoutAnimation(view: topTransition.previous.value.view) strongSelf.state.transition = nil - topTransition.previous.value.setIgnoreAppearanceMethodInvocations(true) - topTransition.previous.value.displayNode.removeFromSupernode() - topTransition.previous.value.setIgnoreAppearanceMethodInvocations(false) + if strongSelf.shouldAnimateDisappearance { + let displayNode = topTransition.previous.value.displayNode + displayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak displayNode] _ in + displayNode?.removeFromSupernode() + }) + } else { + topTransition.previous.value.setIgnoreAppearanceMethodInvocations(true) + topTransition.previous.value.displayNode.removeFromSupernode() + topTransition.previous.value.setIgnoreAppearanceMethodInvocations(false) + } topTransition.previous.value.viewDidDisappear(true) if let toValue = strongSelf.state.top, let layout = strongSelf.state.layout { toValue.value.displayNode.frame = CGRect(origin: CGPoint(), size: layout.size) diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift index 7871390813..2f117a1741 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposer.swift @@ -7,10 +7,6 @@ import MetalKit import Display import SwiftSignalKit import TelegramCore -import AnimatedStickerNode -import TelegramAnimatedStickerNode -import YuvConversion -import StickerResources public func mediaEditorGenerateGradientImage(size: CGSize, colors: [UIColor]) -> UIImage? { UIGraphicsBeginImageContextWithOptions(size, false, 1.0) @@ -280,298 +276,3 @@ private func makeEditorImageFrameComposition(inputImage: CIImage, gradientImage: } maybeFinalize() } - -private func composerEntityForDrawingEntity(account: Account, entity: DrawingEntity, colorSpace: CGColorSpace) -> MediaEditorComposerEntity? { - if let entity = entity as? DrawingStickerEntity { - let content: MediaEditorComposerStickerEntity.Content - switch entity.content { - case let .file(file): - content = .file(file) - case let .image(image): - content = .image(image) - } - return MediaEditorComposerStickerEntity(account: account, content: content, position: entity.position, scale: entity.scale, rotation: entity.rotation, baseSize: entity.baseSize, mirrored: entity.mirrored, colorSpace: colorSpace) - } else if let renderImage = entity.renderImage, let image = CIImage(image: renderImage, options: [.colorSpace: colorSpace]) { - if let entity = entity as? DrawingBubbleEntity { - return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: 1.0, rotation: entity.rotation, baseSize: entity.size, mirrored: false) - } else if let entity = entity as? DrawingSimpleShapeEntity { - return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: 1.0, rotation: entity.rotation, baseSize: entity.size, mirrored: false) - } else if let entity = entity as? DrawingVectorEntity { - return MediaEditorComposerStaticEntity(image: image, position: CGPoint(x: entity.drawingSize.width * 0.5, y: entity.drawingSize.height * 0.5), scale: 1.0, rotation: 0.0, baseSize: entity.drawingSize, mirrored: false) - } else if let entity = entity as? DrawingTextEntity { - return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: entity.scale, rotation: entity.rotation, baseSize: nil, mirrored: false) - } - } - return nil -} - -private class MediaEditorComposerStaticEntity: MediaEditorComposerEntity { - let image: CIImage - let position: CGPoint - let scale: CGFloat - let rotation: CGFloat - let baseSize: CGSize? - let mirrored: Bool - - init(image: CIImage, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize?, mirrored: Bool) { - self.image = image - self.position = position - self.scale = scale - self.rotation = rotation - self.baseSize = baseSize - self.mirrored = mirrored - } - - func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) { - completion(self.image) - } -} - -private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { - public enum Content { - case file(TelegramMediaFile) - case image(UIImage) - - var file: TelegramMediaFile? { - if case let .file(file) = self { - return file - } - return nil - } - } - - let content: Content - let position: CGPoint - let scale: CGFloat - let rotation: CGFloat - let baseSize: CGSize? - let mirrored: Bool - let colorSpace: CGColorSpace - - var isAnimated: Bool - var source: AnimatedStickerNodeSource? - var frameSource = Promise?>() - - var frameCount: Int? - var frameRate: Int? - var currentFrameIndex: Int? - var totalDuration: Double? - let durationPromise = Promise() - - let queue = Queue() - let disposables = DisposableSet() - - var image: CIImage? - var imagePixelBuffer: CVPixelBuffer? - let imagePromise = Promise() - - init(account: Account, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace) { - self.content = content - self.position = position - self.scale = scale - self.rotation = rotation - self.baseSize = baseSize - self.mirrored = mirrored - self.colorSpace = colorSpace - - switch content { - case let .file(file): - if file.isAnimatedSticker || file.isVideoSticker || file.mimeType == "video/webm" { - self.isAnimated = true - self.source = AnimatedStickerResourceSource(account: account, resource: file.resource, isVideo: file.isVideoSticker || file.mimeType == "video/webm") - let pathPrefix = account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) - if let source = self.source { - let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512) - let fittedDimensions = dimensions.cgSize.aspectFitted(CGSize(width: 384, height: 384)) - self.disposables.add((source.directDataPath(attemptSynchronously: true) - |> deliverOn(self.queue)).start(next: { [weak self] path in - if let strongSelf = self, let path { - if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedRead]) { - let queue = strongSelf.queue - let frameSource = QueueLocalObject(queue: queue, generate: { - return AnimatedStickerDirectFrameSource(queue: queue, data: data, width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), cachePathPrefix: pathPrefix, useMetalCache: false, fitzModifier: nil)! - }) - frameSource.syncWith { frameSource in - strongSelf.frameCount = frameSource.frameCount - strongSelf.frameRate = frameSource.frameRate - - let duration = Double(frameSource.frameCount) / Double(frameSource.frameRate) - strongSelf.totalDuration = duration - strongSelf.durationPromise.set(.single(duration)) - } - - strongSelf.frameSource.set(.single(frameSource)) - } - } - })) - } - } else { - self.isAnimated = false - self.disposables.add((chatMessageSticker(account: account, userLocation: .other, file: file, small: false, fetched: true, onlyFullSize: true, thumbnail: false, synchronousLoad: false, colorSpace: self.colorSpace) - |> deliverOn(self.queue)).start(next: { [weak self] generator in - if let self { - let context = generator(TransformImageArguments(corners: ImageCorners(), imageSize: baseSize, boundingSize: baseSize, intrinsicInsets: UIEdgeInsets())) - let image = context?.generateImage(colorSpace: self.colorSpace) - if let image { - self.imagePromise.set(.single(image)) - } - } - })) - } - case let .image(image): - self.isAnimated = false - self.imagePromise.set(.single(image)) - } - } - - deinit { - self.disposables.dispose() - } - - var tested = false - func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) { - if self.isAnimated { - let currentTime = CMTimeGetSeconds(time) - - var tintColor: UIColor? - if let file = self.content.file, file.isCustomTemplateEmoji { - tintColor = .white - } - - self.disposables.add((self.frameSource.get() - |> take(1) - |> deliverOn(self.queue)).start(next: { [weak self] frameSource in - guard let strongSelf = self else { - completion(nil) - return - } - - guard let frameSource, let duration = strongSelf.totalDuration, let frameCount = strongSelf.frameCount else { - completion(nil) - return - } - - let relativeTime = currentTime - floor(currentTime / duration) * duration - var t = relativeTime / duration - t = max(0.0, t) - t = min(1.0, t) - - let startFrame: Double = 0 - let endFrame = Double(frameCount) - - let frameOffset = Int(Double(startFrame) * (1.0 - t) + Double(endFrame - 1) * t) - let lowerBound: Int = 0 - let upperBound = frameCount - 1 - let frameIndex = max(lowerBound, min(upperBound, frameOffset)) - - let currentFrameIndex = strongSelf.currentFrameIndex - if currentFrameIndex != frameIndex { - let previousFrameIndex = currentFrameIndex - strongSelf.currentFrameIndex = frameIndex - - var delta = 1 - if let previousFrameIndex = previousFrameIndex { - delta = max(1, frameIndex - previousFrameIndex) - } - - var frame: AnimatedStickerFrame? - frameSource.syncWith { frameSource in - for i in 0 ..< delta { - frame = frameSource.takeFrame(draw: i == delta - 1) - } - } - if let frame { - var imagePixelBuffer: CVPixelBuffer? - if let pixelBuffer = strongSelf.imagePixelBuffer { - imagePixelBuffer = pixelBuffer - } else { - let ioSurfaceProperties = NSMutableDictionary() - let options = NSMutableDictionary() - options.setObject(ioSurfaceProperties, forKey: kCVPixelBufferIOSurfacePropertiesKey as NSString) - - var pixelBuffer: CVPixelBuffer? - CVPixelBufferCreate( - kCFAllocatorDefault, - frame.width, - frame.height, - kCVPixelFormatType_32BGRA, - options, - &pixelBuffer - ) - - imagePixelBuffer = pixelBuffer - strongSelf.imagePixelBuffer = pixelBuffer - } - - if let imagePixelBuffer { - let image = render(width: frame.width, height: frame.height, bytesPerRow: frame.bytesPerRow, data: frame.data, type: frame.type, pixelBuffer: imagePixelBuffer, colorSpace: strongSelf.colorSpace, tintColor: tintColor) - strongSelf.image = image - } - completion(strongSelf.image) - } else { - completion(nil) - } - } else { - completion(strongSelf.image) - } - })) - } else { - var image: CIImage? - if let cachedImage = self.image { - image = cachedImage - completion(image) - } else { - let _ = (self.imagePromise.get() - |> take(1) - |> deliverOn(self.queue)).start(next: { [weak self] image in - if let self { - self.image = CIImage(image: image, options: [.colorSpace: self.colorSpace]) - completion(self.image) - } - }) - } - } - } -} - -protocol MediaEditorComposerEntity { - var position: CGPoint { get } - var scale: CGFloat { get } - var rotation: CGFloat { get } - var baseSize: CGSize? { get } - var mirrored: Bool { get } - - func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) -} - -private func render(width: Int, height: Int, bytesPerRow: Int, data: Data, type: AnimationRendererFrameType, pixelBuffer: CVPixelBuffer, colorSpace: CGColorSpace, tintColor: UIColor?) -> CIImage? { - //let calculatedBytesPerRow = (4 * Int(width) + 31) & (~31) - //assert(bytesPerRow == calculatedBytesPerRow) - - - CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) - let dest = CVPixelBufferGetBaseAddress(pixelBuffer) - - switch type { - case .yuva: - data.withUnsafeBytes { buffer -> Void in - guard let bytes = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return - } - decodeYUVAToRGBA(bytes, dest, Int32(width), Int32(height), Int32(width * 4)) - } - case .argb: - data.withUnsafeBytes { buffer -> Void in - guard let bytes = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return - } - memcpy(dest, bytes, data.count) - } - case .dct: - break - } - - CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) - - return CIImage(cvPixelBuffer: pixelBuffer, options: [.colorSpace: colorSpace]) -} diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift new file mode 100644 index 0000000000..f628c941f6 --- /dev/null +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift @@ -0,0 +1,329 @@ +import Foundation +import AVFoundation +import UIKit +import CoreImage +import Metal +import MetalKit +import Display +import SwiftSignalKit +import TelegramCore +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import YuvConversion +import StickerResources + +func composerEntityForDrawingEntity(account: Account, entity: DrawingEntity, colorSpace: CGColorSpace) -> MediaEditorComposerEntity? { + if let entity = entity as? DrawingStickerEntity { + let content: MediaEditorComposerStickerEntity.Content + switch entity.content { + case let .file(file): + content = .file(file) + case let .image(image): + content = .image(image) + } + return MediaEditorComposerStickerEntity(account: account, content: content, position: entity.position, scale: entity.scale, rotation: entity.rotation, baseSize: entity.baseSize, mirrored: entity.mirrored, colorSpace: colorSpace) + } else if let renderImage = entity.renderImage, let image = CIImage(image: renderImage, options: [.colorSpace: colorSpace]) { + if let entity = entity as? DrawingBubbleEntity { + return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: 1.0, rotation: entity.rotation, baseSize: entity.size, mirrored: false) + } else if let entity = entity as? DrawingSimpleShapeEntity { + return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: 1.0, rotation: entity.rotation, baseSize: entity.size, mirrored: false) + } else if let entity = entity as? DrawingVectorEntity { + return MediaEditorComposerStaticEntity(image: image, position: CGPoint(x: entity.drawingSize.width * 0.5, y: entity.drawingSize.height * 0.5), scale: 1.0, rotation: 0.0, baseSize: entity.drawingSize, mirrored: false) + } else if let entity = entity as? DrawingTextEntity { + return MediaEditorComposerStaticEntity(image: image, position: entity.position, scale: entity.scale, rotation: entity.rotation, baseSize: nil, mirrored: false) + } + } + return nil +} + +private class MediaEditorComposerStaticEntity: MediaEditorComposerEntity { + let image: CIImage + let position: CGPoint + let scale: CGFloat + let rotation: CGFloat + let baseSize: CGSize? + let mirrored: Bool + + init(image: CIImage, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize?, mirrored: Bool) { + self.image = image + self.position = position + self.scale = scale + self.rotation = rotation + self.baseSize = baseSize + self.mirrored = mirrored + } + + func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) { + completion(self.image) + } +} + +private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { + public enum Content { + case file(TelegramMediaFile) + case image(UIImage) + + var file: TelegramMediaFile? { + if case let .file(file) = self { + return file + } + return nil + } + } + + let content: Content + let position: CGPoint + let scale: CGFloat + let rotation: CGFloat + let baseSize: CGSize? + let mirrored: Bool + let colorSpace: CGColorSpace + + var isAnimated: Bool + var source: AnimatedStickerNodeSource? + var frameSource = Promise?>() + var videoFrameSource = Promise?>() + var isVideo = false + + var frameCount: Int? + var frameRate: Int? + var currentFrameIndex: Int? + var totalDuration: Double? + let durationPromise = Promise() + + let queue = Queue() + let disposables = DisposableSet() + + var image: CIImage? + var imagePixelBuffer: CVPixelBuffer? + let imagePromise = Promise() + + init(account: Account, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace) { + self.content = content + self.position = position + self.scale = scale + self.rotation = rotation + self.baseSize = baseSize + self.mirrored = mirrored + self.colorSpace = colorSpace + + switch content { + case let .file(file): + if file.isAnimatedSticker || file.isVideoSticker || file.mimeType == "video/webm" { + self.isAnimated = true + self.isVideo = file.isVideoSticker || file.mimeType == "video/webm" + + self.source = AnimatedStickerResourceSource(account: account, resource: file.resource, isVideo: isVideo) + let pathPrefix = account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) + if let source = self.source { + let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512) + let fittedDimensions = dimensions.cgSize.aspectFitted(CGSize(width: 384, height: 384)) + self.disposables.add((source.directDataPath(attemptSynchronously: true) + |> deliverOn(self.queue)).start(next: { [weak self] path in + if let strongSelf = self, let path { + if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedRead]) { + let queue = strongSelf.queue + + if strongSelf.isVideo { + let frameSource = QueueLocalObject(queue: queue, generate: { + return VideoStickerDirectFrameSource(queue: queue, path: path, width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), cachePathPrefix: pathPrefix, unpremultiplyAlpha: false)! + }) + frameSource.syncWith { frameSource in + strongSelf.frameCount = frameSource.frameCount + strongSelf.frameRate = frameSource.frameRate + + let duration = Double(frameSource.frameCount) / Double(frameSource.frameRate) + strongSelf.totalDuration = duration + strongSelf.durationPromise.set(.single(duration)) + } + + strongSelf.videoFrameSource.set(.single(frameSource)) + } else { + let frameSource = QueueLocalObject(queue: queue, generate: { + return AnimatedStickerDirectFrameSource(queue: queue, data: data, width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), cachePathPrefix: pathPrefix, useMetalCache: false, fitzModifier: nil)! + }) + frameSource.syncWith { frameSource in + strongSelf.frameCount = frameSource.frameCount + strongSelf.frameRate = frameSource.frameRate + + let duration = Double(frameSource.frameCount) / Double(frameSource.frameRate) + strongSelf.totalDuration = duration + strongSelf.durationPromise.set(.single(duration)) + } + + strongSelf.frameSource.set(.single(frameSource)) + } + } + } + })) + } + } else { + self.isAnimated = false + self.disposables.add((chatMessageSticker(account: account, userLocation: .other, file: file, small: false, fetched: true, onlyFullSize: true, thumbnail: false, synchronousLoad: false, colorSpace: self.colorSpace) + |> deliverOn(self.queue)).start(next: { [weak self] generator in + if let self { + let context = generator(TransformImageArguments(corners: ImageCorners(), imageSize: baseSize, boundingSize: baseSize, intrinsicInsets: UIEdgeInsets())) + let image = context?.generateImage(colorSpace: self.colorSpace) + if let image { + self.imagePromise.set(.single(image)) + } + } + })) + } + case let .image(image): + self.isAnimated = false + self.imagePromise.set(.single(image)) + } + } + + deinit { + self.disposables.dispose() + } + + var tested = false + func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) { + if self.isAnimated { + let currentTime = CMTimeGetSeconds(time) + + var tintColor: UIColor? + if let file = self.content.file, file.isCustomTemplateEmoji { + tintColor = .white + } + + self.disposables.add((self.frameSource.get() + |> take(1) + |> deliverOn(self.queue)).start(next: { [weak self] frameSource in + guard let strongSelf = self else { + completion(nil) + return + } + + guard let frameSource, let duration = strongSelf.totalDuration, let frameCount = strongSelf.frameCount else { + completion(nil) + return + } + + let relativeTime = currentTime - floor(currentTime / duration) * duration + var t = relativeTime / duration + t = max(0.0, t) + t = min(1.0, t) + + let startFrame: Double = 0 + let endFrame = Double(frameCount) + + let frameOffset = Int(Double(startFrame) * (1.0 - t) + Double(endFrame - 1) * t) + let lowerBound: Int = 0 + let upperBound = frameCount - 1 + let frameIndex = max(lowerBound, min(upperBound, frameOffset)) + + let currentFrameIndex = strongSelf.currentFrameIndex + if currentFrameIndex != frameIndex { + let previousFrameIndex = currentFrameIndex + strongSelf.currentFrameIndex = frameIndex + + var delta = 1 + if let previousFrameIndex = previousFrameIndex { + delta = max(1, frameIndex - previousFrameIndex) + } + + var frame: AnimatedStickerFrame? + frameSource.syncWith { frameSource in + for i in 0 ..< delta { + frame = frameSource.takeFrame(draw: i == delta - 1) + } + } + if let frame { + var imagePixelBuffer: CVPixelBuffer? + if let pixelBuffer = strongSelf.imagePixelBuffer { + imagePixelBuffer = pixelBuffer + } else { + let ioSurfaceProperties = NSMutableDictionary() + let options = NSMutableDictionary() + options.setObject(ioSurfaceProperties, forKey: kCVPixelBufferIOSurfacePropertiesKey as NSString) + + var pixelBuffer: CVPixelBuffer? + CVPixelBufferCreate( + kCFAllocatorDefault, + frame.width, + frame.height, + kCVPixelFormatType_32BGRA, + options, + &pixelBuffer + ) + + imagePixelBuffer = pixelBuffer + strongSelf.imagePixelBuffer = pixelBuffer + } + + if let imagePixelBuffer { + let image = render(width: frame.width, height: frame.height, bytesPerRow: frame.bytesPerRow, data: frame.data, type: frame.type, pixelBuffer: imagePixelBuffer, colorSpace: strongSelf.colorSpace, tintColor: tintColor) + strongSelf.image = image + } + completion(strongSelf.image) + } else { + completion(nil) + } + } else { + completion(strongSelf.image) + } + })) + } else { + var image: CIImage? + if let cachedImage = self.image { + image = cachedImage + completion(image) + } else { + let _ = (self.imagePromise.get() + |> take(1) + |> deliverOn(self.queue)).start(next: { [weak self] image in + if let self { + self.image = CIImage(image: image, options: [.colorSpace: self.colorSpace]) + completion(self.image) + } + }) + } + } + } +} + +protocol MediaEditorComposerEntity { + var position: CGPoint { get } + var scale: CGFloat { get } + var rotation: CGFloat { get } + var baseSize: CGSize? { get } + var mirrored: Bool { get } + + func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) +} + +private func render(width: Int, height: Int, bytesPerRow: Int, data: Data, type: AnimationRendererFrameType, pixelBuffer: CVPixelBuffer, colorSpace: CGColorSpace, tintColor: UIColor?) -> CIImage? { + //let calculatedBytesPerRow = (4 * Int(width) + 31) & (~31) + //assert(bytesPerRow == calculatedBytesPerRow) + + + CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) + let dest = CVPixelBufferGetBaseAddress(pixelBuffer) + + switch type { + case .yuva: + data.withUnsafeBytes { buffer -> Void in + guard let bytes = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return + } + decodeYUVAToRGBA(bytes, dest, Int32(width), Int32(height), Int32(width * 4)) + } + case .argb: + data.withUnsafeBytes { buffer -> Void in + guard let bytes = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return + } + memcpy(dest, bytes, data.count) + } + case .dct: + break + } + + CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) + + return CIImage(cvPixelBuffer: pixelBuffer, options: [.colorSpace: colorSpace]) +} From 5661640f3834ad6ba97fefdbfe95a38ee498f952 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 Jun 2023 03:41:14 +0400 Subject: [PATCH 2/3] Story view iPad layout improvements --- .../Sources/MediaEditorScreen.swift | 1 + .../Sources/StoryPreviewComponent.swift | 1 + .../Sources/MessageInputPanelComponent.swift | 14 ++++++- .../Sources/StoryContainerScreen.swift | 9 +++-- .../StoryItemSetContainerComponent.swift | 39 +++++++++++++++---- 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 186ad786c2..319880d560 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -671,6 +671,7 @@ final class MediaEditorScreenComponent: Component { strings: environment.strings, style: .editor, placeholder: "Add a caption...", + alwaysDarkWhenHasText: false, presentController: { [weak self] c in guard let self, let _ = self.component else { return diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift index 2c48d81991..d270647c45 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift @@ -250,6 +250,7 @@ final class StoryPreviewComponent: Component { strings: presentationData.strings, style: .story, placeholder: "Reply Privately...", + alwaysDarkWhenHasText: false, presentController: { _ in }, sendMessageAction: { diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 60657da8a9..90c6b3bf70 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -29,6 +29,7 @@ public final class MessageInputPanelComponent: Component { public let strings: PresentationStrings public let style: Style public let placeholder: String + public let alwaysDarkWhenHasText: Bool public let presentController: (ViewController) -> Void public let sendMessageAction: () -> Void public let setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)? @@ -55,6 +56,7 @@ public final class MessageInputPanelComponent: Component { strings: PresentationStrings, style: Style, placeholder: String, + alwaysDarkWhenHasText: Bool, presentController: @escaping (ViewController) -> Void, sendMessageAction: @escaping () -> Void, setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)?, @@ -80,6 +82,7 @@ public final class MessageInputPanelComponent: Component { self.strings = strings self.style = style self.placeholder = placeholder + self.alwaysDarkWhenHasText = alwaysDarkWhenHasText self.presentController = presentController self.sendMessageAction = sendMessageAction self.setMediaRecordingActive = setMediaRecordingActive @@ -119,6 +122,9 @@ public final class MessageInputPanelComponent: Component { if lhs.placeholder != rhs.placeholder { return false } + if lhs.alwaysDarkWhenHasText != rhs.alwaysDarkWhenHasText { + return false + } if lhs.audioRecorder !== rhs.audioRecorder { return false } @@ -718,7 +724,13 @@ public final class MessageInputPanelComponent: Component { } } - self.fieldBackgroundView.updateColor(color: self.textFieldExternalState.isEditing || component.style == .editor ? UIColor(white: 0.0, alpha: 0.5) : UIColor(white: 1.0, alpha: 0.09), transition: transition.containedViewLayoutTransition) + var fieldBackgroundIsDark = false + if self.textFieldExternalState.hasText && component.alwaysDarkWhenHasText { + fieldBackgroundIsDark = true + } else if self.textFieldExternalState.isEditing || component.style == .editor { + fieldBackgroundIsDark = true + } + self.fieldBackgroundView.updateColor(color: fieldBackgroundIsDark ? UIColor(white: 0.0, alpha: 0.5) : UIColor(white: 1.0, alpha: 0.09), transition: transition.containedViewLayoutTransition) if let placeholder = self.placeholder.view, let vibrancyPlaceholderView = self.vibrancyPlaceholder.view { placeholder.isHidden = self.textFieldExternalState.hasText vibrancyPlaceholderView.isHidden = placeholder.isHidden diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 39792a8157..5a63af2df3 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -556,14 +556,15 @@ private final class StoryContainerScreenComponent: Component { } var itemSetContainerSize = availableSize - var itemSetContainerTopInset = environment.statusBarHeight + 12.0 + var itemSetContainerInsets = UIEdgeInsets(top: environment.statusBarHeight + 12.0, left: 0.0, bottom: 0.0, right: 0.0) var itemSetContainerSafeInsets = environment.safeInsets if case .regular = environment.metrics.widthClass { let availableHeight = min(1080.0, availableSize.height - max(45.0, environment.safeInsets.bottom) * 2.0) let mediaHeight = availableHeight - 40.0 let mediaWidth = floor(mediaHeight * 0.5625) itemSetContainerSize = CGSize(width: mediaWidth, height: availableHeight) - itemSetContainerTopInset = 0.0 + itemSetContainerInsets.top = 0.0 + itemSetContainerInsets.bottom = floorToScreenPixels((availableSize.height - itemSetContainerSize.height) / 2.0) itemSetContainerSafeInsets.bottom = 0.0 } @@ -575,13 +576,14 @@ private final class StoryContainerScreenComponent: Component { slice: slice, theme: environment.theme, strings: environment.strings, - containerInsets: UIEdgeInsets(top: itemSetContainerTopInset, left: 0.0, bottom: environment.inputHeight, right: 0.0), + containerInsets: itemSetContainerInsets, safeInsets: itemSetContainerSafeInsets, inputHeight: environment.inputHeight, metrics: environment.metrics, isProgressPaused: isProgressPaused || i != focusedIndex, hideUI: i == focusedIndex && self.itemSetPanState?.didBegin == false, visibilityFraction: 1.0 - abs(panFraction + cubeAdditionalRotationFraction), + isPanning: self.itemSetPanState?.didBegin == true, presentController: { [weak self] c in guard let self, let environment = self.environment else { return @@ -670,6 +672,7 @@ private final class StoryContainerScreenComponent: Component { self.addSubview(itemSetView) } if itemSetComponentView.superview == nil { + itemSetView.tintLayer.isDoubleSided = false itemSetComponentView.layer.isDoubleSided = false itemSetView.addSubview(itemSetComponentView) itemSetView.layer.addSublayer(itemSetView.tintLayer) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index ff5eecd2ac..8b8d03ea20 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -44,6 +44,7 @@ public final class StoryItemSetContainerComponent: Component { public let isProgressPaused: Bool public let hideUI: Bool public let visibilityFraction: CGFloat + public let isPanning: Bool public let presentController: (ViewController) -> Void public let close: () -> Void public let navigate: (NavigationDirection) -> Void @@ -63,6 +64,7 @@ public final class StoryItemSetContainerComponent: Component { isProgressPaused: Bool, hideUI: Bool, visibilityFraction: CGFloat, + isPanning: Bool, presentController: @escaping (ViewController) -> Void, close: @escaping () -> Void, navigate: @escaping (NavigationDirection) -> Void, @@ -81,6 +83,7 @@ public final class StoryItemSetContainerComponent: Component { self.isProgressPaused = isProgressPaused self.hideUI = hideUI self.visibilityFraction = visibilityFraction + self.isPanning = isPanning self.presentController = presentController self.close = close self.navigate = navigate @@ -122,6 +125,9 @@ public final class StoryItemSetContainerComponent: Component { if lhs.visibilityFraction != rhs.visibilityFraction { return false } + if lhs.isPanning != rhs.isPanning { + return false + } return true } @@ -788,6 +794,7 @@ public final class StoryItemSetContainerComponent: Component { //self.updatePreloads() + let wasPanning = self.component?.isPanning ?? false self.component = component self.state = state @@ -797,10 +804,23 @@ public final class StoryItemSetContainerComponent: Component { } else { bottomContentInset = 0.0 } + + var inputPanelAvailableWidth = availableSize.width + var inputPanelTransition = transition + if case .regular = component.metrics.widthClass { + if (self.inputPanelExternalState.isEditing || self.inputPanelExternalState.hasText) { + if wasPanning != component.isPanning { + inputPanelTransition = .easeInOut(duration: 0.25) + } + if !component.isPanning { + inputPanelAvailableWidth += 200.0 + } + } + } self.inputPanel.parentState = state let inputPanelSize = self.inputPanel.update( - transition: transition, + transition: inputPanelTransition, component: AnyComponent(MessageInputPanelComponent( externalState: self.inputPanelExternalState, context: component.context, @@ -808,6 +828,7 @@ public final class StoryItemSetContainerComponent: Component { strings: component.strings, style: .story, placeholder: "Reply Privately...", + alwaysDarkWhenHasText: component.metrics.widthClass == .regular, presentController: { [weak self] c in guard let self, let component = self.component else { return @@ -881,11 +902,11 @@ public final class StoryItemSetContainerComponent: Component { wasRecordingDismissed: self.sendMessageContext.wasRecordingDismissed, timeoutValue: nil, timeoutSelected: false, - displayGradient: component.inputHeight != 0.0, + displayGradient: component.inputHeight != 0.0 && component.metrics.widthClass != .regular, bottomInset: component.inputHeight != 0.0 ? 0.0 : bottomContentInset )), environment: {}, - containerSize: CGSize(width: availableSize.width, height: 200.0) + containerSize: CGSize(width: inputPanelAvailableWidth, height: 200.0) ) var currentItem: StoryContentItem? @@ -1096,11 +1117,15 @@ public final class StoryItemSetContainerComponent: Component { let inputPanelIsOverlay: Bool if component.inputHeight == 0.0 { inputPanelBottomInset = bottomContentInset - bottomContentInset += inputPanelSize.height + if case .regular = component.metrics.widthClass { + bottomContentInset += 60.0 + } else { + bottomContentInset += inputPanelSize.height + } inputPanelIsOverlay = false } else { bottomContentInset += 44.0 - inputPanelBottomInset = component.inputHeight + inputPanelBottomInset = component.inputHeight - component.containerInsets.bottom inputPanelIsOverlay = true } @@ -1295,7 +1320,7 @@ public final class StoryItemSetContainerComponent: Component { let itemLayout = ItemLayout(size: CGSize(width: contentFrame.width, height: availableSize.height - component.containerInsets.top - 44.0 - bottomContentInsetWithoutInput)) self.itemLayout = itemLayout - let inputPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputPanelBottomInset - inputPanelSize.height), size: inputPanelSize) + let inputPanelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - inputPanelSize.width) / 2.0), y: availableSize.height - inputPanelBottomInset - inputPanelSize.height), size: inputPanelSize) var inputPanelAlpha: CGFloat = focusedItem?.isMy == true ? 0.0 : 1.0 if case .regular = component.metrics.widthClass { inputPanelAlpha *= component.visibilityFraction @@ -1304,7 +1329,7 @@ public final class StoryItemSetContainerComponent: Component { if inputPanelView.superview == nil { self.addSubview(inputPanelView) } - transition.setFrame(view: inputPanelView, frame: inputPanelFrame) + inputPanelTransition.setFrame(view: inputPanelView, frame: inputPanelFrame) transition.setAlpha(view: inputPanelView, alpha: inputPanelAlpha) } From 3c274dbf0c5e5e22781681fa69991eb93a53fb6d Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 6 Jun 2023 15:06:22 +0400 Subject: [PATCH 3/3] Camera and editor improvements --- .../TelegramEngine/Messages/Stories.swift | 191 ++++++++++++++---- .../Messages/TelegramEngineMessages.swift | 4 + .../Drawing/DrawingStickerEntity.swift | 2 +- .../MediaEditor/Sources/MediaEditor.swift | 5 + .../Sources/MediaEditorComposerEntity.swift | 6 +- .../Sources/MediaEditorDraft.swift | 32 +++ .../Sources/MediaEditorScreen.swift | 178 ++++++++++------ .../Sources/MediaToolsScreen.swift | 21 ++ .../Sources/TintComponent.swift | 34 +++- .../Sources/MessageInputPanelComponent.swift | 9 +- .../Sources/PostboxKeys.swift | 2 + 11 files changed, 372 insertions(+), 112 deletions(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index 0274ef19a4..16e0f828fd 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -492,7 +492,7 @@ public enum StoryUploadResult { case completed } -func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, text: String, entities: [MessageTextEntity], pin: Bool, privacy: EngineStoryPrivacy) -> Signal { +private func uploadedStoryContent(account: Account, media: EngineStoryInputMedia) -> (signal: Signal, media: Media) { let originalMedia: Media let contentToUpload: MessageContentToUpload @@ -571,48 +571,60 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, text: contentSignal = signal } - return contentSignal - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) + return ( + contentSignal + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + }, + originalMedia + ) +} + +private func apiInputPrivacyRules(privacy: EngineStoryPrivacy, transaction: Transaction) -> [Api.InputPrivacyRule] { + var privacyRules: [Api.InputPrivacyRule] + switch privacy.base { + case .everyone: + privacyRules = [.inputPrivacyValueAllowAll] + case .contacts: + privacyRules = [.inputPrivacyValueAllowContacts] + case .closeFriends: + privacyRules = [.inputPrivacyValueAllowCloseFriends] + case .nobody: + privacyRules = [.inputPrivacyValueDisallowAll] } + var privacyUsers: [Api.InputUser] = [] + var privacyChats: [Int64] = [] + for peerId in privacy.additionallyIncludePeers { + if let peer = transaction.getPeer(peerId) { + if let _ = peer as? TelegramUser { + if let inputUser = apiInputUser(peer) { + privacyUsers.append(inputUser) + } + } else if peer is TelegramGroup || peer is TelegramChannel { + privacyChats.append(peer.id.id._internalGetInt64Value()) + } + } + } + if !privacyUsers.isEmpty { + privacyRules.append(.inputPrivacyValueAllowUsers(users: privacyUsers)) + } + if !privacyChats.isEmpty { + privacyRules.append(.inputPrivacyValueAllowChatParticipants(chats: privacyChats)) + } + return privacyRules +} + +func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, text: String, entities: [MessageTextEntity], pin: Bool, privacy: EngineStoryPrivacy) -> Signal { + let (contentSignal, originalMedia) = uploadedStoryContent(account: account, media: media) + return contentSignal |> mapToSignal { result -> Signal in - return account.postbox.transaction { transaction -> Signal in - switch result { - case let .progress(progress): - return .single(.progress(progress)) - case let .content(content): - var privacyRules: [Api.InputPrivacyRule] - switch privacy.base { - case .everyone: - privacyRules = [.inputPrivacyValueAllowAll] - case .contacts: - privacyRules = [.inputPrivacyValueAllowContacts] - case .closeFriends: - privacyRules = [.inputPrivacyValueAllowCloseFriends] - case .nobody: - privacyRules = [.inputPrivacyValueDisallowAll] - } - var privacyUsers: [Api.InputUser] = [] - var privacyChats: [Int64] = [] - for peerId in privacy.additionallyIncludePeers { - if let peer = transaction.getPeer(peerId) { - if let _ = peer as? TelegramUser { - if let inputUser = apiInputUser(peer) { - privacyUsers.append(inputUser) - } - } else if peer is TelegramGroup || peer is TelegramChannel { - privacyChats.append(peer.id.id._internalGetInt64Value()) - } - } - } - if !privacyUsers.isEmpty { - privacyRules.append(.inputPrivacyValueAllowUsers(users: privacyUsers)) - } - if !privacyChats.isEmpty { - privacyRules.append(.inputPrivacyValueAllowChatParticipants(chats: privacyChats)) - } - + switch result { + case let .progress(progress): + return .single(.progress(progress)) + case let .content(content): + return account.postbox.transaction { transaction -> Signal in + let privacyRules = apiInputPrivacyRules(privacy: privacy, transaction: transaction) switch content.content { case let .media(inputMedia, _): var flags: Int32 = 0 @@ -622,7 +634,6 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, text: if pin { flags |= 1 << 2 } - if !text.isEmpty { flags |= 1 << 0 apiCaption = text @@ -678,8 +689,100 @@ func _internal_uploadStory(account: Account, media: EngineStoryInputMedia, text: default: return .complete() } - default: - return .complete() + } + |> switchToLatest + default: + return .complete() + } + } +} + +func _internal_editStory(account: Account, media: EngineStoryInputMedia?, id: Int32, text: String, entities: [MessageTextEntity], privacy: EngineStoryPrivacy?) -> Signal { + let contentSignal: Signal + let originalMedia: Media? + if let media = media { + (contentSignal, originalMedia) = uploadedStoryContent(account: account, media: media) + } else { + contentSignal = .single(nil) + originalMedia = nil + } + + return contentSignal + |> mapToSignal { result -> Signal in + if let result = result, case let .progress(progress) = result { + return .single(.progress(progress)) + } + + let inputMedia: Api.InputMedia? + if let result = result, case let .content(uploadedContent) = result, case let .media(media, _) = uploadedContent.content { + inputMedia = media + } else { + inputMedia = nil + } + + return account.postbox.transaction { transaction -> Signal in + var flags: Int32 = 0 + var apiCaption: String? + var apiEntities: [Api.MessageEntity]? + var privacyRules: [Api.InputPrivacyRule]? + + if let _ = inputMedia { + flags |= 1 << 0 + } + if !text.isEmpty { + flags |= 1 << 1 + apiCaption = text + + if !entities.isEmpty { + flags |= 1 << 1 + + var associatedPeers: [PeerId: Peer] = [:] + for entity in entities { + for entityPeerId in entity.associatedPeerIds { + if let peer = transaction.getPeer(entityPeerId) { + associatedPeers[peer.id] = peer + } + } + } + apiEntities = apiEntitiesFromMessageTextEntities(entities, associatedPeers: SimpleDictionary(associatedPeers)) + } + } + if let privacy = privacy { + privacyRules = apiInputPrivacyRules(privacy: privacy, transaction: transaction) + flags |= 1 << 2 + } + + return account.network.request(Api.functions.stories.editStory( + flags: flags, + id: id, + media: inputMedia, + caption: apiCaption, + entities: apiEntities, + privacyRules: privacyRules + )) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { updates -> Signal in + if let updates = updates { + for update in updates.allUpdates { + if case let .updateStory(_, story) = update { + switch story { + case let .storyItem(_, _, _, _, _, media, _, _): + let (parsedMedia, _, _, _) = textMediaAndExpirationTimerFromApiMedia(media, account.peerId) + if let parsedMedia = parsedMedia, let originalMedia = originalMedia { + applyMediaResourceChanges(from: originalMedia, to: parsedMedia, postbox: account.postbox, force: false) + } + default: + break + } + } + } + account.stateManager.addUpdates(updates) + } + + return .single(.completed) } } |> switchToLatest diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 3352f8fbfb..36d6016dcd 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -880,6 +880,10 @@ public extension TelegramEngine { return _internal_uploadStory(account: self.account, media: media, text: text, entities: entities, pin: pin, privacy: privacy) } + public func editStory(media: EngineStoryInputMedia?, id: Int32, text: String, entities: [MessageTextEntity], privacy: EngineStoryPrivacy?) -> Signal { + return _internal_editStory(account: self.account, media: media, id: id, text: text, entities: entities, privacy: privacy) + } + public func deleteStory(id: Int32) -> Signal { return _internal_deleteStory(account: self.account, id: id) } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift index f78c8b485d..ec1c9d3ff7 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift @@ -54,7 +54,7 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { } public var baseSize: CGSize { - let size = max(10.0, min(self.referenceDrawingSize.width, self.referenceDrawingSize.height) * 0.2) + let size = max(10.0, min(self.referenceDrawingSize.width, self.referenceDrawingSize.height) * 0.25) return CGSize(width: size, height: size) } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift index e6629b7a8f..7d5554dcdc 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditor.swift @@ -82,6 +82,11 @@ public final class MediaEditor { } set { self.histogramCalculationPass.isEnabled = newValue + if newValue { + Queue.mainQueue().justDispatch { + self.updateRenderChain() + } + } } } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift index f628c941f6..7c5c0f7455 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift @@ -256,7 +256,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { } if let imagePixelBuffer { - let image = render(width: frame.width, height: frame.height, bytesPerRow: frame.bytesPerRow, data: frame.data, type: frame.type, pixelBuffer: imagePixelBuffer, colorSpace: strongSelf.colorSpace, tintColor: tintColor) + let image = render(width: frame.width, height: frame.height, bytesPerRow: frame.bytesPerRow, data: frame.data, type: frame.type, pixelBuffer: imagePixelBuffer, tintColor: tintColor) strongSelf.image = image } completion(strongSelf.image) @@ -296,7 +296,7 @@ protocol MediaEditorComposerEntity { func image(for time: CMTime, frameRate: Float, completion: @escaping (CIImage?) -> Void) } -private func render(width: Int, height: Int, bytesPerRow: Int, data: Data, type: AnimationRendererFrameType, pixelBuffer: CVPixelBuffer, colorSpace: CGColorSpace, tintColor: UIColor?) -> CIImage? { +private func render(width: Int, height: Int, bytesPerRow: Int, data: Data, type: AnimationRendererFrameType, pixelBuffer: CVPixelBuffer, tintColor: UIColor?) -> CIImage? { //let calculatedBytesPerRow = (4 * Int(width) + 31) & (~31) //assert(bytesPerRow == calculatedBytesPerRow) @@ -325,5 +325,5 @@ private func render(width: Int, height: Int, bytesPerRow: Int, data: Data, type: CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) - return CIImage(cvPixelBuffer: pixelBuffer, options: [.colorSpace: colorSpace]) + return CIImage(cvPixelBuffer: pixelBuffer, options: [.colorSpace: deviceColorSpace]) } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift index a3cb382040..32693ee8a6 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift @@ -131,3 +131,35 @@ public func storyDrafts(engine: TelegramEngine) -> Signal<[MediaEditorDraft], No return result } } + +public func saveStorySource(engine: TelegramEngine, item: MediaEditorDraft, id: Int64) { + let key = EngineDataBuffer(length: 8) + key.setInt64(0, value: id) + let _ = engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.storySource, id: key, item: item).start() +} + +public func removeStorySource(engine: TelegramEngine, id: Int64) { + let key = EngineDataBuffer(length: 8) + key.setInt64(0, value: id) + let _ = engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.storySource, id: key) +} + +public func moveStorySource(engine: TelegramEngine, from fromId: Int64, to toId: Int64) { + let fromKey = EngineDataBuffer(length: 8) + fromKey.setInt64(0, value: fromId) + + let toKey = EngineDataBuffer(length: 8) + toKey.setInt64(0, value: toId) + + let _ = engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.storySource, id: fromKey)) + |> mapToSignal { item -> Signal in + if let item = item?.get(MediaEditorDraft.self) { + return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.storySource, id: toKey, item: item) + |> then( + engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.storySource, id: fromKey) + ) + } else { + return .complete() + } + } +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 319880d560..af8e5056a9 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -650,14 +650,28 @@ final class MediaEditorScreenComponent: Component { } - let timeoutValue: Int32 + var timeoutValue: String let timeoutSelected: Bool switch component.privacy { - case let .story(_, archive): - timeoutValue = 24 - timeoutSelected = !archive + case let .story(_, timeout, archive): + switch timeout { + case 21600: + timeoutValue = "6" + case 43200: + timeoutValue = "12" + case 86400: + timeoutValue = "24" + case 172800: + timeoutValue = "2d" + default: + timeoutValue = "24" + } + if archive { + timeoutValue = "∞" + } + timeoutSelected = false case let .message(_, timeout): - timeoutValue = timeout ?? 1 + timeoutValue = "\(timeout ?? 1)" timeoutSelected = timeout != nil } @@ -694,13 +708,7 @@ final class MediaEditorScreenComponent: Component { guard let self, let controller = self.environment?.controller() as? MediaEditorScreen else { return } - switch controller.state.privacy { - case let .story(privacy, archive): - controller.state.privacy = .story(privacy: privacy, archive: !archive) - controller.node.presentStoryArchiveTooltip(sourceView: view) - case .message: - controller.presentTimeoutSetup(sourceView: view) - } + controller.presentTimeoutSetup(sourceView: view) }, audioRecorder: nil, videoRecordingStatus: nil, @@ -742,7 +750,7 @@ final class MediaEditorScreenComponent: Component { let privacyText: String switch component.privacy { - case let .story(privacy, _): + case let .story(privacy, _, _): switch privacy.base { case .everyone: privacyText = "Everyone" @@ -1027,7 +1035,7 @@ private let storyDimensions = CGSize(width: 1080.0, height: 1920.0) private let storyMaxVideoDuration: Double = 60.0 public enum MediaEditorResultPrivacy: Equatable { - case story(privacy: EngineStoryPrivacy, archive: Bool) + case story(privacy: EngineStoryPrivacy, timeout: Int32, archive: Bool) case message(peers: [EnginePeer.Id], timeout: Int32?) } @@ -1070,7 +1078,7 @@ public final class MediaEditorScreen: ViewController { } struct State { - var privacy: MediaEditorResultPrivacy = .story(privacy: EngineStoryPrivacy(base: .everyone, additionallyIncludePeers: []), archive: false) + var privacy: MediaEditorResultPrivacy = .story(privacy: EngineStoryPrivacy(base: .everyone, additionallyIncludePeers: []), timeout: 86400, archive: false) } var state = State() { @@ -1788,7 +1796,7 @@ public final class MediaEditorScreen: ViewController { private weak var storyArchiveTooltip: ViewController? func presentStoryArchiveTooltip(sourceView: UIView) { - guard let controller = self.controller, case let .story(_, archive) = controller.state.privacy else { + guard let controller = self.controller, case let .story(_, _, archive) = controller.state.privacy else { return } @@ -2158,9 +2166,13 @@ public final class MediaEditorScreen: ViewController { return } + var archive = true + var timeout: Int32 = 86400 let initialPrivacy: EngineStoryPrivacy - if case let .story(privacy, _) = self.state.privacy { + if case let .story(privacy, timeoutValue, archiveValue) = self.state.privacy { initialPrivacy = privacy + timeout = timeoutValue + archive = archiveValue } else { initialPrivacy = EngineStoryPrivacy(base: .everyone, additionallyIncludePeers: []) } @@ -2174,7 +2186,7 @@ public final class MediaEditorScreen: ViewController { guard let self else { return } - self.state.privacy = .story(privacy: privacy, archive: true) + self.state.privacy = .story(privacy: privacy, timeout: timeout, archive: archive) }, editCategory: { [weak self] privacy in guard let self else { @@ -2184,7 +2196,7 @@ public final class MediaEditorScreen: ViewController { guard let self else { return } - self.state.privacy = .story(privacy: privacy, archive: true) + self.state.privacy = .story(privacy: privacy, timeout: timeout, archive: archive) self.presentPrivacySettings() }) }, @@ -2265,54 +2277,104 @@ public final class MediaEditorScreen: ViewController { func presentTimeoutSetup(sourceView: UIView) { var items: [ContextMenuItem] = [] - let updateTimeout: (Int32?) -> Void = { [weak self] timeout in + let updateTimeout: (Int32?, Bool) -> Void = { [weak self] timeout, archive in guard let self else { return } - if case let .message(peers, _) = self.state.privacy { + switch self.state.privacy { + case let .story(privacy, _, _): + self.state.privacy = .story(privacy: privacy, timeout: timeout ?? 86400, archive: archive) + case let .message(peers, _): self.state.privacy = .message(peers: peers, timeout: timeout) } } let emptyAction: ((ContextMenuActionItem.Action) -> Void)? = nil - items.append(.action(ContextMenuActionItem(text: "Choose how long the media will be kept after opening.", textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction))) + let title: String + switch self.state.privacy { + case .story: + title = "Choose how long the story will be kept." + case .message: + title = "Choose how long the media will be kept after opening." + } + + items.append(.action(ContextMenuActionItem(text: title, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction))) + + switch self.state.privacy { + case .story: + items.append(.action(ContextMenuActionItem(text: "6 Hours", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(3600 * 6, false) + }))) + items.append(.action(ContextMenuActionItem(text: "12 Hours", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(3600 * 12, false) + }))) + items.append(.action(ContextMenuActionItem(text: "24 Hours", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(86400, false) + }))) + items.append(.action(ContextMenuActionItem(text: "2 Days", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(86400 * 2, false) + }))) + items.append(.action(ContextMenuActionItem(text: "Forever", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(86400, true) + }))) + case .message: + items.append(.action(ContextMenuActionItem(text: "Until First View", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(1, false) + }))) + items.append(.action(ContextMenuActionItem(text: "3 Seconds", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(3, false) + }))) + items.append(.action(ContextMenuActionItem(text: "10 Seconds", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(10, false) + }))) + items.append(.action(ContextMenuActionItem(text: "1 Minute", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(60, false) + }))) + items.append(.action(ContextMenuActionItem(text: "Keep Always", icon: { _ in + return nil + }, action: { _, a in + a(.default) + + updateTimeout(nil, false) + }))) + } - items.append(.action(ContextMenuActionItem(text: "Until First View", icon: { _ in - return nil - }, action: { _, a in - a(.default) - - updateTimeout(1) - }))) - items.append(.action(ContextMenuActionItem(text: "3 Seconds", icon: { _ in - return nil - }, action: { _, a in - a(.default) - - updateTimeout(3) - }))) - items.append(.action(ContextMenuActionItem(text: "10 Seconds", icon: { _ in - return nil - }, action: { _, a in - a(.default) - - updateTimeout(10) - }))) - items.append(.action(ContextMenuActionItem(text: "1 Minute", icon: { _ in - return nil - }, action: { _, a in - a(.default) - - updateTimeout(60) - }))) - items.append(.action(ContextMenuActionItem(text: "Keep Always", icon: { _ in - return nil - }, action: { _, a in - a(.default) - - updateTimeout(nil) - }))) - let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkPresentationTheme) let contextController = ContextController(account: self.context.account, presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) self.present(contextController, in: .window(.root)) diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift index d2a066a61a..74d90fd7aa 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaToolsScreen.swift @@ -662,6 +662,17 @@ private final class MediaToolsScreenComponent: Component { controller.mediaEditor.setToolValue(.highlightsTint, value: value) state?.updated() } + }, + isTrackingUpdated: { [weak self] isTracking in + if let self { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + transition.setAlpha(view: self.optionsBackgroundView, alpha: isTracking ? 0.0 : 1.0) + } } )), environment: {}, @@ -867,6 +878,9 @@ public final class MediaToolsScreen: ViewController { } func animateOutToEditor(completion: @escaping () -> Void) { + if let mediaEditor = self.controller?.mediaEditor { + mediaEditor.play() + } if let view = self.componentHost.view as? MediaToolsScreenComponent.View { view.animateOutToEditor(completion: completion) } @@ -921,6 +935,13 @@ public final class MediaToolsScreen: ViewController { sectionUpdated: { [weak self] section in if let self { self.currentSection = section + if let mediaEditor = self.controller?.mediaEditor { + if section == .curves { + mediaEditor.stop() + } else { + mediaEditor.play() + } + } if let layout = self.validLayout { self.containerLayoutUpdated(layout: layout, transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift index cf8a3dcdcd..6f7fe7d810 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/TintComponent.swift @@ -110,17 +110,20 @@ final class TintComponent: Component { let highlightsValue: TintValue let shadowsValueUpdated: (TintValue) -> Void let highlightsValueUpdated: (TintValue) -> Void + let isTrackingUpdated: (Bool) -> Void init( shadowsValue: TintValue, highlightsValue: TintValue, shadowsValueUpdated: @escaping (TintValue) -> Void, - highlightsValueUpdated: @escaping (TintValue) -> Void + highlightsValueUpdated: @escaping (TintValue) -> Void, + isTrackingUpdated: @escaping (Bool) -> Void ) { self.shadowsValue = shadowsValue self.highlightsValue = highlightsValue self.shadowsValueUpdated = shadowsValueUpdated self.highlightsValueUpdated = highlightsValueUpdated + self.isTrackingUpdated = isTrackingUpdated } static func ==(lhs: TintComponent, rhs: TintComponent) -> Bool { @@ -300,6 +303,32 @@ final class TintComponent: Component { sizes.append(size) } + let isTrackingUpdated: (Bool) -> Void = { [weak self] isTracking in + component.isTrackingUpdated(isTracking) + + if let self { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + + let alpha: CGFloat = isTracking ? 0.0 : 1.0 + if let view = self.shadowsButton.view { + transition.setAlpha(view: view, alpha: alpha) + } + if let view = self.highlightsButton.view { + transition.setAlpha(view: view, alpha: alpha) + } + for color in self.colorViews { + if let view = color.view { + transition.setAlpha(view: view, alpha: alpha) + } + } + } + } + let sliderSize = self.slider.update( transition: transition, component: AnyComponent( @@ -321,6 +350,9 @@ final class TintComponent: Component { highlightsValueUpdated(state.highlightsValue.withUpdatedIntensity(value)) } } + }, + isTrackingUpdated: { isTracking in + isTrackingUpdated(isTracking) } ) ), diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 90c6b3bf70..dd20db5f01 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -44,7 +44,7 @@ public final class MessageInputPanelComponent: Component { public let isRecordingLocked: Bool public let recordedAudioPreview: ChatRecordedMediaPreview? public let wasRecordingDismissed: Bool - public let timeoutValue: Int32? + public let timeoutValue: String? public let timeoutSelected: Bool public let displayGradient: Bool public let bottomInset: CGFloat @@ -71,7 +71,7 @@ public final class MessageInputPanelComponent: Component { isRecordingLocked: Bool, recordedAudioPreview: ChatRecordedMediaPreview?, wasRecordingDismissed: Bool, - timeoutValue: Int32?, + timeoutValue: String?, timeoutSelected: Bool, displayGradient: Bool, bottomInset: CGFloat @@ -673,10 +673,9 @@ public final class MessageInputPanelComponent: Component { } if let timeoutAction = component.timeoutAction, let timeoutValue = component.timeoutValue { - func generateIcon(value: Int32) -> UIImage? { + func generateIcon(value: String) -> UIImage? { let image = UIImage(bundleImageName: "Media Editor/Timeout")! - let string = "\(value)" - let valueString = NSAttributedString(string: "\(value)", font: Font.with(size: string.count == 1 ? 12.0 : 10.0, design: .round, weight: .semibold), textColor: .white, paragraphAlignment: .center) + let valueString = NSAttributedString(string: value, font: Font.with(size: value.count == 1 ? 12.0 : 10.0, design: .round, weight: .semibold), textColor: .white, paragraphAlignment: .center) return generateImage(image.size, contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift index 8756172cbd..a88995398a 100644 --- a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift +++ b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift @@ -76,6 +76,7 @@ private enum ApplicationSpecificItemCacheCollectionIdValues: Int8 { case cachedImageRecognizedContent = 6 case pendingInAppPurchaseState = 7 case translationState = 10 + case storySource = 11 } public struct ApplicationSpecificItemCacheCollectionId { @@ -88,6 +89,7 @@ public struct ApplicationSpecificItemCacheCollectionId { public static let cachedImageRecognizedContent = applicationSpecificItemCacheCollectionId(ApplicationSpecificItemCacheCollectionIdValues.cachedImageRecognizedContent.rawValue) public static let pendingInAppPurchaseState = applicationSpecificItemCacheCollectionId(ApplicationSpecificItemCacheCollectionIdValues.pendingInAppPurchaseState.rawValue) public static let translationState = applicationSpecificItemCacheCollectionId(ApplicationSpecificItemCacheCollectionIdValues.translationState.rawValue) + public static let storySource = applicationSpecificItemCacheCollectionId(ApplicationSpecificItemCacheCollectionIdValues.storySource.rawValue) } private enum ApplicationSpecificOrderedItemListCollectionIdValues: Int32 {