Repository navigation
perf(subtitles): retain bitmap images with deferred PNG backing #879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import Foundation | ||
| import CoreGraphics | ||
| import ImageIO | ||
| import UniformTypeIdentifiers | ||
| import Compression | ||
| import AetherLibavcodec | ||
| import AetherLibavformat | ||
|
|
@@ -622,7 +624,8 @@ final class EmbeddedSubtitleDecoder { | |
|
|
||
| /// Render a PGS/DVB/HDMV bitmap rect into a CGImage with normalised position. | ||
| /// Palette from libavcodec is 32-bit with alpha in the high byte and BGR below ([B,G,R,A] on little-endian); | ||
| /// rewritten to RGBA for CGImage's premultipliedLast. Internal for unit tests (#146). | ||
| /// rewritten to premultiplied RGBA, then retained losslessly with deferred pixel decoding. | ||
| /// Internal for unit tests (#146). | ||
| static func imageForSubtitleRect( | ||
| _ rect: UnsafeMutablePointer<AVSubtitleRect>, | ||
| videoWidth: Int, | ||
|
|
@@ -724,8 +727,40 @@ final class EmbeddedSubtitleDecoder { | |
| position = CGRect(x: 0.1, y: 0.8, width: 0.8, height: 0.15) | ||
| } | ||
|
|
||
| return SubtitleImage(cgImage: cgImage, position: position, | ||
| guard let retained = retainedBitmap(cgImage) else { | ||
| EngineLog.emit("[subtitle] lossless bitmap storage failed size=\(cropW)x\(cropH)") | ||
| return nil | ||
| } | ||
| return SubtitleImage(cgImage: retained.image, position: position, | ||
| canvasSize: CGSize(width: Int(videoWidth), height: Int(videoHeight)), | ||
| isForced: (r.flags & AV_SUBTITLE_FLAG_FORCED) != 0) | ||
| isForced: (r.flags & AV_SUBTITLE_FLAG_FORCED) != 0, | ||
| encodedBackingBytes: retained.encodedByteCount) | ||
| } | ||
|
|
||
| /// The subtitle history needs authored images for offsets and backward seeks, but does not | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment holds for The cue store already prunes cues more than Please settle this with a device footprint run on the real presentation path before relying on the PNG backing. The other option is to bound the retained set by bytes and re-decode evicted cues from the packet store. That bound holds however the host draws. |
||
| /// need every image expanded to RGBA at once. Retain PNG bytes through ImageIO's provider; | ||
| /// drawing decodes the same pixels without caching them for the whole cue lifetime. This | ||
| /// runs on the existing subtitle decode worker, not on the presentation actor. | ||
| static func retainedBitmap(_ image: CGImage) -> (image: CGImage, encodedByteCount: Int)? { | ||
| autoreleasepool { | ||
| let data = NSMutableData() | ||
| guard let destination = CGImageDestinationCreateWithData( | ||
| data, UTType.png.identifier as CFString, 1, nil | ||
| ) else { return nil } | ||
| CGImageDestinationAddImage(destination, image, nil) | ||
| guard CGImageDestinationFinalize(destination) else { return nil } | ||
| let options = [kCGImageSourceShouldCache: false] as CFDictionary | ||
| guard let source = CGImageSourceCreateWithData(data, options), | ||
| let decoded = CGImageSourceCreateImageAtIndex(source, 0, options), | ||
| let colorSpace = decoded.colorSpace, let provider = decoded.dataProvider, | ||
| let retained = CGImage( | ||
| width: decoded.width, height: decoded.height, | ||
| bitsPerComponent: decoded.bitsPerComponent, bitsPerPixel: decoded.bitsPerPixel, | ||
| bytesPerRow: decoded.bytesPerRow, space: colorSpace, | ||
| bitmapInfo: decoded.bitmapInfo, provider: provider, decode: nil, | ||
| shouldInterpolate: image.shouldInterpolate, intent: image.renderingIntent | ||
| ) else { return nil } | ||
| return (retained, data.length) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1619,7 +1619,9 @@ extension SubtitleCue: Equatable { | |
| } | ||
| } | ||
|
|
||
| /// Decoded PGS / HDMV PGS / DVB / DVD bitmap subtitle. CGImage is fully rendered (RGBA, premultiplied alpha). Position is [0, 1] against the source video frame; multiply by the on-screen video rect to place it. | ||
| /// Decoded PGS / HDMV PGS / DVB / DVD bitmap subtitle. CGImage carries the rendered image and | ||
| /// alpha metadata; its provider may defer pixel expansion. Draw it rather than assuming a raw | ||
| /// premultiplied RGBA provider layout. Position is [0, 1] against the source video frame. | ||
| public struct SubtitleImage: @unchecked Sendable { | ||
| public let cgImage: CGImage | ||
| public let position: CGRect | ||
|
|
@@ -1632,12 +1634,17 @@ public struct SubtitleImage: @unchecked Sendable { | |
| /// AV_SUBTITLE_FLAG_FORCED from the decoded rect (#146): the disc authored this object as a | ||
| /// forced caption (sign / foreign-dialogue overlay shown even with subtitles off). | ||
| public let isForced: Bool | ||
| /// Known compressed provider storage, when the decoder retains a lossless encoded image. | ||
| /// This excludes ImageIO/CGImage overhead and temporary pixels used while drawing. | ||
| public let encodedBackingBytes: Int? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: what would a host do with this value? The doc says it excludes ImageIO/CGImage overhead and decoded pixels. The maintainer measured that after Core Animation presentation the real cost is the full RGBA plane plus these bytes, so the number can be off by more than an order of magnitude. Once a field like this is public, hosts will sum it as a memory figure. If only the tests need it, consider keeping it internal; |
||
|
|
||
| public init(cgImage: CGImage, position: CGRect, canvasSize: CGSize = .zero, isForced: Bool = false) { | ||
| public init(cgImage: CGImage, position: CGRect, canvasSize: CGSize = .zero, isForced: Bool = false, | ||
| encodedBackingBytes: Int? = nil) { | ||
| self.cgImage = cgImage | ||
| self.position = position | ||
| self.canvasSize = canvasSize | ||
| self.isForced = isForced | ||
| self.encodedBackingBytes = encodedBackingBytes | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import CoreGraphics | ||
| import Foundation | ||
| import Testing | ||
| @testable import AetherEngine | ||
|
|
||
| struct SubtitleBitmapStorageTests { | ||
| @Test("retained subtitle images preserve rendered alpha after temporary storage is released") | ||
| func losslessPixelsAndProviderLifetime() throws { | ||
| let width = 256, height = 32 | ||
| var pixels = Data(count: width * height * 4) | ||
| pixels.withUnsafeMutableBytes { (bytes: UnsafeMutableRawBufferPointer) in | ||
| for y in 0..<height { | ||
| for x in 0..<width { | ||
| let offset = (y * width + x) * 4 | ||
| let alpha = x | ||
| bytes[offset] = UInt8(((y * 7 % 256) * alpha + 127) / 255) | ||
| bytes[offset + 1] = UInt8(((x * 13 % 256) * alpha + 127) / 255) | ||
| bytes[offset + 2] = UInt8(((x * 23 % 256) * alpha + 127) / 255) | ||
| bytes[offset + 3] = UInt8(alpha) | ||
| } | ||
| } | ||
| } | ||
| let original = try #require(makeImage(pixels, width: width, height: height)) | ||
| let retained = try #require(autoreleasepool { EmbeddedSubtitleDecoder.retainedBitmap(original) }) | ||
| #expect(retained.encodedByteCount > 0) | ||
| #expect(retained.image.width == width) | ||
| #expect(retained.image.height == height) | ||
| #expect(!retained.image.shouldInterpolate) | ||
| #expect(render(retained.image) == render(original)) | ||
| // Repeated presentation and backward seek use the same retained provider. | ||
| #expect(render(retained.image) == render(original)) | ||
| } | ||
|
|
||
| @Test("flat subtitle pixels retain compressed backing rather than a full RGBA plane") | ||
| func flatSubtitleStorageIsSmaller() throws { | ||
| let width = 1024, height = 96 | ||
| var pixels = Data(count: width * height * 4) | ||
| pixels.withUnsafeMutableBytes { (bytes: UnsafeMutableRawBufferPointer) in | ||
| for y in 8..<88 { | ||
| for x in 16..<1008 where (x + y) % 12 < 4 { | ||
| let offset = (y * width + x) * 4 | ||
| bytes[offset] = 128; bytes[offset + 1] = 64 | ||
| bytes[offset + 2] = 32; bytes[offset + 3] = 128 | ||
| } | ||
| } | ||
| } | ||
| let original = try #require(makeImage(pixels, width: width, height: height)) | ||
| let retained = try #require(EmbeddedSubtitleDecoder.retainedBitmap(original)) | ||
| #expect(retained.encodedByteCount < pixels.count) | ||
| #expect(render(retained.image) == render(original)) | ||
| let image = SubtitleImage(cgImage: retained.image, position: .zero, | ||
| encodedBackingBytes: retained.encodedByteCount) | ||
| #expect(image.encodedBackingBytes == retained.encodedByteCount) | ||
| } | ||
|
|
||
| private func makeImage(_ data: Data, width: Int, height: Int) -> CGImage? { | ||
| guard let provider = CGDataProvider(data: data as CFData), | ||
| let space = CGColorSpace(name: CGColorSpace.sRGB) else { return nil } | ||
| return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, | ||
| bytesPerRow: width * 4, space: space, | ||
| bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), | ||
| provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) | ||
| } | ||
|
|
||
| private func render(_ image: CGImage) -> Data { | ||
| var data = Data(count: image.width * image.height * 4) | ||
| data.withUnsafeMutableBytes { bytes in | ||
| let context = CGContext(data: bytes.baseAddress, width: image.width, height: image.height, | ||
| bitsPerComponent: 8, bytesPerRow: image.width * 4, | ||
| space: CGColorSpace(name: CGColorSpace.sRGB)!, | ||
| bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! | ||
| context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) | ||
| } | ||
| return data | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a host-visible contract change, not a fix. Before this PR,
SubtitleImagedocumented a fully rendered premultiplied RGBA image. The PNG-backed provider from ImageIO can expose straight alpha and a different layout, so a host that readscgImage.dataProviderbytes gets wrong pixels without any error. Please move this entry to### Changedand lead with the contract change. ListencodedBackingBytesunder### Added, if it stays public.