Rewrite gif rendering

This commit is contained in:
Isaac 2025-02-19 13:22:12 +01:00
parent 91074ca83a
commit 54ee2c17aa
5 changed files with 407 additions and 38 deletions

View File

@ -429,10 +429,6 @@ public final class FFMpegFileReader {
if let stream = self.stream, Int(packet.streamIndex) == stream.info.index {
let packetPts = packet.pts
/*if let focusedPart = self.focusedPart, packetPts >= focusedPart.endPts.value {
self.hasReadToEnd = true
}*/
let pts = CMTimeMake(value: packetPts, timescale: stream.info.timeScale)
let dts = CMTimeMake(value: packet.dts, timescale: stream.info.timeScale)
@ -442,7 +438,7 @@ public final class FFMpegFileReader {
if frameDuration != 0 {
duration = CMTimeMake(value: frameDuration * stream.info.timeBase, timescale: stream.info.timeScale)
} else {
duration = stream.info.fps
duration = CMTimeConvertScale(CMTimeMakeWithSeconds(1.0 / stream.info.fps.seconds, preferredTimescale: stream.info.timeScale), timescale: stream.info.timeScale, method: .quickTime)
}
let frame = MediaTrackDecodableFrame(type: .video, packet: packet, pts: pts, dts: dts, duration: duration)

View File

@ -0,0 +1,22 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "BatchVideoRendering",
module_name = "BatchVideoRendering",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/Display",
"//submodules/AccountContext",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramCore",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,341 @@
import Foundation
import UIKit
import Display
import UniversalMediaPlayer
import AccountContext
import SwiftSignalKit
import TelegramCore
import CoreMedia
public protocol BatchVideoRenderingContextTarget: AnyObject {
var batchVideoRenderingTargetState: BatchVideoRenderingContext.TargetState? { get set }
func setSampleBuffer(sampleBuffer: CMSampleBuffer)
}
public final class BatchVideoRenderingContext {
public typealias Target = BatchVideoRenderingContextTarget
public final class TargetHandle {
private weak var context: BatchVideoRenderingContext?
private let id: Int
init(context: BatchVideoRenderingContext, id: Int) {
self.context = context
self.id = id
}
deinit {
self.context?.targetRemoved(id: self.id)
}
}
public final class TargetState {
var currentFrameExpirationTimestamp: Double?
init() {
}
}
private final class ReadingContext {
let dataPath: String
var isFailed: Bool = false
var reader: FFMpegFileReader?
init(dataPath: String) {
self.dataPath = dataPath
}
func advance() -> CMSampleBuffer? {
outer: while true {
if self.isFailed {
break outer
}
if self.reader == nil {
let reader = FFMpegFileReader(
source: .file(self.dataPath),
useHardwareAcceleration: false,
selectedStream: .mediaType(.video),
seek: nil,
maxReadablePts: nil
)
if reader == nil {
self.isFailed = true
break outer
}
self.reader = reader
}
guard let reader = self.reader else {
break outer
}
switch reader.readFrame() {
case let .frame(frame):
return createSampleBuffer(fromSampleBuffer: frame.sampleBuffer, withTimeOffset: .zero, duration: nil, displayImmediately: true)
case .error:
self.isFailed = true
break outer
case .endOfStream:
self.reader = nil
case .waitingForMoreData:
self.isFailed = true
break outer
}
}
return nil
}
}
private final class TargetContext {
weak var target: Target?
let file: TelegramMediaFile
let userLocation: MediaResourceUserLocation
var readingContext: QueueLocalObject<ReadingContext>?
var fetchDisposable: Disposable?
var dataDisposable: Disposable?
var dataPath: String?
init(
target: Target,
file: TelegramMediaFile,
userLocation: MediaResourceUserLocation
) {
self.target = target
self.file = file
self.userLocation = userLocation
}
deinit {
self.fetchDisposable?.dispose()
self.dataDisposable?.dispose()
}
}
private static let sharedQueue = Queue(name: "BatchVideoRenderingContext", qos: .default)
private let context: AccountContext
private var targetContexts: [Int: TargetContext] = [:]
private var nextId: Int = 0
private var isRendering: Bool = false
private var displayLink: SharedDisplayLinkDriver.Link?
public init(context: AccountContext) {
self.context = context
}
public func add(target: Target, file: TelegramMediaFile, userLocation: MediaResourceUserLocation) -> TargetHandle {
let id = self.nextId
self.nextId += 1
self.targetContexts[id] = TargetContext(
target: target,
file: file,
userLocation: userLocation
)
self.update()
return TargetHandle(context: self, id: id)
}
private func targetRemoved(id: Int) {
if self.targetContexts.removeValue(forKey: id) != nil {
self.update()
}
}
private func update() {
var removeIds: [Int] = []
for (id, targetContext) in self.targetContexts {
if targetContext.target != nil {
if targetContext.fetchDisposable == nil {
//TODO:release pass resource reference
targetContext.fetchDisposable = fetchedMediaResource(
mediaBox: self.context.account.postbox.mediaBox,
userLocation: targetContext.userLocation,
userContentType: .sticker,
reference: .media(media: .standalone(media: targetContext.file), resource: targetContext.file.resource)
).startStrict()
}
if targetContext.dataDisposable == nil {
targetContext.dataDisposable = (self.context.account.postbox.mediaBox.resourceData(targetContext.file.resource)
|> deliverOnMainQueue).startStrict(next: { [weak self, weak targetContext] data in
guard let self, let targetContext else {
return
}
if data.complete && targetContext.dataPath == nil {
targetContext.dataPath = data.path
self.update()
}
})
}
if targetContext.readingContext == nil, let dataPath = targetContext.dataPath {
targetContext.readingContext = QueueLocalObject(queue: BatchVideoRenderingContext.sharedQueue, generate: {
return ReadingContext(dataPath: dataPath)
})
}
} else {
removeIds.append(id)
}
}
for id in removeIds {
self.targetContexts.removeValue(forKey: id)
}
if !self.targetContexts.isEmpty {
if self.displayLink == nil {
self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else {
return
}
self.updateRendering()
}
}
} else {
self.displayLink = nil
}
}
private func updateRendering() {
if self.isRendering {
return
}
let timestamp = CACurrentMediaTime()
var removeIds: [Int] = []
var renderIds: [Int] = []
for (id, targetContext) in self.targetContexts {
guard let target = targetContext.target else {
removeIds.append(id)
continue
}
let targetState: TargetState
if let current = target.batchVideoRenderingTargetState {
targetState = current
} else {
targetState = TargetState()
target.batchVideoRenderingTargetState = targetState
}
if let currentFrameExpirationTimestamp = targetState.currentFrameExpirationTimestamp {
if timestamp >= currentFrameExpirationTimestamp {
renderIds.append(id)
}
} else {
renderIds.append(id)
}
}
for id in removeIds {
self.targetContexts.removeValue(forKey: id)
}
if !renderIds.isEmpty {
self.isRendering = true
var readingContexts: [Int: QueueLocalObject<ReadingContext>] = [:]
for id in renderIds {
guard let targetContext = self.targetContexts[id] else {
continue
}
if let readingContext = targetContext.readingContext {
readingContexts[id] = readingContext
}
}
BatchVideoRenderingContext.sharedQueue.async { [weak self] in
var sampleBuffers: [Int: CMSampleBuffer?] = [:]
for (id, readingContext) in readingContexts {
guard let readingContext = readingContext.unsafeGet() else {
sampleBuffers[id] = nil
continue
}
if let sampleBuffer = readingContext.advance() {
sampleBuffers[id] = sampleBuffer
} else {
sampleBuffers[id] = nil
}
}
Queue.mainQueue().async {
guard let self else {
return
}
self.isRendering = false
for (id, sampleBuffer) in sampleBuffers {
guard let targetContext = self.targetContexts[id], let target = targetContext.target, let targetState = target.batchVideoRenderingTargetState else {
return
}
if let sampleBuffer {
target.setSampleBuffer(sampleBuffer: sampleBuffer)
if let targetState = target.batchVideoRenderingTargetState {
targetState.currentFrameExpirationTimestamp = CACurrentMediaTime() + CMSampleBufferGetDuration(sampleBuffer).seconds
}
} else {
targetState.currentFrameExpirationTimestamp = CACurrentMediaTime() + 1.0 / 30.0
}
}
}
}
}
if !self.targetContexts.isEmpty {
if self.displayLink == nil {
self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else {
return
}
self.updateRendering()
}
}
} else {
self.displayLink = nil
}
}
}
private func createSampleBuffer(fromSampleBuffer sampleBuffer: CMSampleBuffer, withTimeOffset timeOffset: CMTime, duration: CMTime?, displayImmediately: Bool) -> CMSampleBuffer? {
var itemCount: CMItemCount = 0
var status = CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &itemCount)
if status != 0 {
return nil
}
var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(duration: CMTimeMake(value: 0, timescale: 0), presentationTimeStamp: CMTimeMake(value: 0, timescale: 0), decodeTimeStamp: CMTimeMake(value: 0, timescale: 0)), count: itemCount)
status = CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: itemCount, arrayToFill: &timingInfo, entriesNeededOut: &itemCount)
if status != 0 {
return nil
}
if let dur = duration {
for i in 0 ..< itemCount {
timingInfo[i].decodeTimeStamp = CMTimeAdd(timingInfo[i].decodeTimeStamp, timeOffset)
timingInfo[i].presentationTimeStamp = CMTimeAdd(timingInfo[i].presentationTimeStamp, timeOffset)
timingInfo[i].duration = dur
}
} else {
for i in 0 ..< itemCount {
timingInfo[i].decodeTimeStamp = CMTimeAdd(timingInfo[i].decodeTimeStamp, timeOffset)
timingInfo[i].presentationTimeStamp = CMTimeAdd(timingInfo[i].presentationTimeStamp, timeOffset)
}
}
var sampleBufferOffset: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: itemCount, sampleTimingArray: &timingInfo, sampleBufferOut: &sampleBufferOffset)
guard let sampleBufferOffset else {
return nil
}
if displayImmediately {
let attachments: NSArray = CMSampleBufferGetSampleAttachmentsArray(sampleBufferOffset, createIfNecessary: true)! as NSArray
let dict: NSMutableDictionary = attachments[0] as! NSMutableDictionary
dict[kCMSampleAttachmentKey_DisplayImmediately as NSString] = true as NSNumber
}
return sampleBufferOffset
}

View File

@ -50,6 +50,7 @@ swift_library(
"//submodules/TelegramUIPreferences",
"//submodules/TelegramCore/FlatBuffers",
"//submodules/TelegramCore/FlatSerialization",
"//submodules/TelegramUI/Components/BatchVideoRendering",
],
visibility = [
"//visibility:public",

View File

@ -19,18 +19,20 @@ import SoftwareVideo
import AVFoundation
import PhotoResources
import ShimmerEffect
import BatchVideoRendering
private class GifVideoLayer: AVSampleBufferDisplayLayer {
private class GifVideoLayer: AVSampleBufferDisplayLayer, BatchVideoRenderingContext.Target {
private let context: AccountContext
private let batchVideoContext: BatchVideoRenderingContext
private let userLocation: MediaResourceUserLocation
private let file: TelegramMediaFile?
private var frameManager: SoftwareVideoLayerFrameManager?
private var batchVideoTargetHandle: BatchVideoRenderingContext.TargetHandle?
var batchVideoRenderingTargetState: BatchVideoRenderingContext.TargetState?
private var thumbnailDisposable: Disposable?
private var playbackTimestamp: Double = 0.0
private var playbackTimer: SwiftSignalKit.Timer?
private var isReadyToRender: Bool = false
var started: (() -> Void)?
@ -39,28 +41,13 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer {
if self.shouldBeAnimating == oldValue {
return
}
if self.shouldBeAnimating {
self.playbackTimer?.invalidate()
let startTimestamp = self.playbackTimestamp + CFAbsoluteTimeGetCurrent()
self.playbackTimer = SwiftSignalKit.Timer(timeout: 1.0 / 30.0, repeat: true, completion: { [weak self] in
guard let strongSelf = self else {
return
}
let timestamp = CFAbsoluteTimeGetCurrent() - startTimestamp
strongSelf.frameManager?.tick(timestamp: timestamp)
strongSelf.playbackTimestamp = timestamp
}, queue: .mainQueue())
self.playbackTimer?.start()
} else {
self.playbackTimer?.invalidate()
self.playbackTimer = nil
}
self.updateShouldBeRendering()
}
}
init(context: AccountContext, userLocation: MediaResourceUserLocation, file: TelegramMediaFile?, synchronousLoad: Bool) {
init(context: AccountContext, batchVideoContext: BatchVideoRenderingContext, userLocation: MediaResourceUserLocation, file: TelegramMediaFile?, synchronousLoad: Bool) {
self.context = context
self.batchVideoContext = batchVideoContext
self.userLocation = userLocation
self.file = file
@ -102,6 +89,7 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer {
}
self.context = layer.context
self.batchVideoContext = layer.batchVideoContext
self.userLocation = layer.userLocation
self.file = layer.file
@ -117,18 +105,28 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer {
}
private func setupVideo() {
guard let file = self.file else {
return
}
let frameManager = SoftwareVideoLayerFrameManager(account: self.context.account, userLocation: self.userLocation, userContentType: .other, fileReference: .savedGif(media: file), layerHolder: nil, layer: self)
self.frameManager = frameManager
frameManager.started = { [weak self] in
guard let strongSelf = self else {
return
self.isReadyToRender = true
self.updateShouldBeRendering()
}
private func updateShouldBeRendering() {
let shouldBeRendering = self.shouldBeAnimating && self.isReadyToRender
if shouldBeRendering, let file = self.file {
if self.batchVideoTargetHandle == nil {
self.batchVideoTargetHandle = self.batchVideoContext.add(target: self, file: file, userLocation: self.userLocation)
}
let _ = strongSelf
} else {
self.batchVideoTargetHandle = nil
}
}
func setSampleBuffer(sampleBuffer: CMSampleBuffer) {
if #available(iOS 17.0, *) {
self.sampleBufferRenderer.enqueue(sampleBuffer)
} else {
self.enqueue(sampleBuffer)
}
frameManager.start()
}
}
@ -382,6 +380,7 @@ public final class GifPagerContentComponent: Component {
init(
item: Item?,
context: AccountContext,
batchVideoContext: BatchVideoRenderingContext,
groupId: String,
attemptSynchronousLoad: Bool,
onUpdateDisplayPlaceholder: @escaping (Bool, Double) -> Void
@ -389,7 +388,7 @@ public final class GifPagerContentComponent: Component {
self.item = item
self.onUpdateDisplayPlaceholder = onUpdateDisplayPlaceholder
super.init(context: context, userLocation: .other, file: item?.file.media, synchronousLoad: attemptSynchronousLoad)
super.init(context: context, batchVideoContext: batchVideoContext, userLocation: .other, file: item?.file.media, synchronousLoad: attemptSynchronousLoad)
if item == nil {
self.updateDisplayPlaceholder(displayPlaceholder: true, duration: 0.0)
@ -594,6 +593,7 @@ public final class GifPagerContentComponent: Component {
private var pagerEnvironment: PagerComponentChildEnvironment?
private var theme: PresentationTheme?
private var itemLayout: ItemLayout?
private var batchVideoContext: BatchVideoRenderingContext?
private var currentLoadMoreToken: String?
@ -833,6 +833,14 @@ public final class GifPagerContentComponent: Component {
searchInset += itemLayout.searchHeight
}
let batchVideoContext: BatchVideoRenderingContext
if let current = self.batchVideoContext {
batchVideoContext = current
} else {
batchVideoContext = BatchVideoRenderingContext(context: component.context)
self.batchVideoContext = batchVideoContext
}
if let itemRange = itemLayout.visibleItems(for: self.scrollView.bounds) {
for index in itemRange.lowerBound ..< itemRange.upperBound {
var item: Item?
@ -869,6 +877,7 @@ public final class GifPagerContentComponent: Component {
itemLayer = ItemLayer(
item: item,
context: component.context,
batchVideoContext: batchVideoContext,
groupId: "savedGif",
attemptSynchronousLoad: attemptSynchronousLoads,
onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder, duration in