Skip to content

Commit 095c294

Browse files
Isaacclaude
andcommitted
feat: add SubcodecAnimationCacheImpl writer and ARGB-to-YUV conversion
Implements utility helpers, BT.709 ARGB→YUV 4:2:0 conversion with alpha un-premultiplication, and AnimationCacheItemWriterImpl that feeds frames to SCSprite and writes metadata alongside the .mbs output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e8dc34c commit 095c294

File tree

1 file changed

+286
-0
lines changed

1 file changed

+286
-0
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import Foundation
2+
import UIKit
3+
import SwiftSignalKit
4+
import CryptoUtils
5+
import ManagedFile
6+
import AnimationCache
7+
import SubcodecObjC
8+
9+
public struct MbsMetadata {
10+
public let frameCount: Int
11+
public let frameDurations: [Double]
12+
}
13+
14+
private func md5Hash(_ string: String) -> String {
15+
let hashData = string.data(using: .utf8)!.withUnsafeBytes { bytes -> Data in
16+
return CryptoMD5(bytes.baseAddress!, Int32(bytes.count))
17+
}
18+
return hashData.withUnsafeBytes { bytes -> String in
19+
let uintBytes = bytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
20+
return String(format: "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", uintBytes[0], uintBytes[1], uintBytes[2], uintBytes[3], uintBytes[4], uintBytes[5], uintBytes[6], uintBytes[7], uintBytes[8], uintBytes[9], uintBytes[10], uintBytes[11], uintBytes[12], uintBytes[13], uintBytes[14], uintBytes[15])
21+
}
22+
}
23+
24+
private func itemSubpath(hashString: String, width: Int, height: Int) -> (directory: String, fileName: String) {
25+
assert(hashString.count == 32)
26+
let directory = String(hashString[hashString.startIndex ..< hashString.index(hashString.startIndex, offsetBy: 2)])
27+
return (directory, "\(hashString)_\(width)x\(height)")
28+
}
29+
30+
private func roundUp(_ numToRound: Int, multiple: Int) -> Int {
31+
if multiple == 0 {
32+
return numToRound
33+
}
34+
let remainder = numToRound % multiple
35+
if remainder == 0 {
36+
return numToRound
37+
}
38+
return numToRound + multiple - remainder
39+
}
40+
41+
private func convertARGBToYUVA420(
42+
argb: UnsafePointer<UInt8>,
43+
width: Int,
44+
height: Int,
45+
bytesPerRow: Int
46+
) -> (y: Data, cb: Data, cr: Data, alpha: Data, yStride: Int, cbStride: Int, crStride: Int, alphaStride: Int) {
47+
let chromaWidth = width / 2
48+
let chromaHeight = height / 2
49+
50+
var yData = Data(count: width * height)
51+
var cbData = Data(count: chromaWidth * chromaHeight)
52+
var crData = Data(count: chromaWidth * chromaHeight)
53+
var alphaData = Data(count: width * height)
54+
55+
yData.withUnsafeMutableBytes { yBuf in
56+
cbData.withUnsafeMutableBytes { cbBuf in
57+
crData.withUnsafeMutableBytes { crBuf in
58+
alphaData.withUnsafeMutableBytes { aBuf in
59+
let yPtr = yBuf.baseAddress!.assumingMemoryBound(to: UInt8.self)
60+
let cbPtr = cbBuf.baseAddress!.assumingMemoryBound(to: UInt8.self)
61+
let crPtr = crBuf.baseAddress!.assumingMemoryBound(to: UInt8.self)
62+
let aPtr = aBuf.baseAddress!.assumingMemoryBound(to: UInt8.self)
63+
64+
for row in 0 ..< height {
65+
let srcRow = argb.advanced(by: row * bytesPerRow)
66+
for col in 0 ..< width {
67+
let px = srcRow.advanced(by: col * 4)
68+
// BGRA layout (CoreGraphics premultiplied)
69+
let b = Int(px[0])
70+
let g = Int(px[1])
71+
let r = Int(px[2])
72+
let a = Int(px[3])
73+
74+
// Un-premultiply
75+
let rr: Int
76+
let gg: Int
77+
let bb: Int
78+
if a > 0 {
79+
rr = min(255, r * 255 / a)
80+
gg = min(255, g * 255 / a)
81+
bb = min(255, b * 255 / a)
82+
} else {
83+
rr = 0
84+
gg = 0
85+
bb = 0
86+
}
87+
88+
// BT.709
89+
let y = 16 + (65 * rr + 129 * gg + 25 * bb + 128) / 256
90+
yPtr[row * width + col] = UInt8(clamping: y)
91+
aPtr[row * width + col] = UInt8(a)
92+
}
93+
}
94+
95+
// Chroma at half resolution (average 2x2 blocks)
96+
for row in 0 ..< chromaHeight {
97+
for col in 0 ..< chromaWidth {
98+
var sumR = 0
99+
var sumG = 0
100+
var sumB = 0
101+
for dy in 0 ..< 2 {
102+
for dx in 0 ..< 2 {
103+
let srcRow = argb.advanced(by: (row * 2 + dy) * bytesPerRow)
104+
let px = srcRow.advanced(by: (col * 2 + dx) * 4)
105+
let b = Int(px[0])
106+
let g = Int(px[1])
107+
let r = Int(px[2])
108+
let a = Int(px[3])
109+
if a > 0 {
110+
sumR += min(255, r * 255 / a)
111+
sumG += min(255, g * 255 / a)
112+
sumB += min(255, b * 255 / a)
113+
}
114+
}
115+
}
116+
let avgR = sumR / 4
117+
let avgG = sumG / 4
118+
let avgB = sumB / 4
119+
120+
let cb = 128 + (-38 * avgR - 74 * avgG + 112 * avgB + 128) / 256
121+
let cr = 128 + (112 * avgR - 94 * avgG - 18 * avgB + 128) / 256
122+
cbPtr[row * chromaWidth + col] = UInt8(clamping: cb)
123+
crPtr[row * chromaWidth + col] = UInt8(clamping: cr)
124+
}
125+
}
126+
}
127+
}
128+
}
129+
}
130+
131+
return (yData, cbData, crData, alphaData, width, chromaWidth, chromaWidth, width)
132+
}
133+
134+
private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter {
135+
struct CompressedResult {
136+
var mbsPath: String
137+
var metaPath: String
138+
}
139+
140+
var queue: Queue {
141+
return self.innerQueue
142+
}
143+
let innerQueue: Queue
144+
var isCancelled: Bool = false
145+
146+
private let mbsOutputPath: String
147+
private let metaOutputPath: String
148+
private let completion: (CompressedResult?) -> Void
149+
150+
private var spriteExtractor: SCSprite?
151+
private var frameDurations: [Double] = []
152+
private var isFailed: Bool = false
153+
private var isFinished: Bool = false
154+
private var spriteWidth: Int = 0
155+
private var spriteHeight: Int = 0
156+
157+
private let lock = Lock()
158+
159+
init?(queue: Queue, allocateTempFile: @escaping () -> String, completion: @escaping (CompressedResult?) -> Void) {
160+
self.innerQueue = queue
161+
self.mbsOutputPath = allocateTempFile()
162+
self.metaOutputPath = allocateTempFile()
163+
self.completion = completion
164+
}
165+
166+
func add(with drawingBlock: (AnimationCacheItemDrawingSurface) -> Double?, proposedWidth: Int, proposedHeight: Int, insertKeyframe: Bool) {
167+
self.lock.locked {
168+
if self.isFailed || self.isFinished {
169+
return
170+
}
171+
172+
let width = roundUp(proposedWidth, multiple: 16)
173+
let height = roundUp(proposedHeight, multiple: 16)
174+
175+
if width == 0 || height == 0 {
176+
self.isFailed = true
177+
return
178+
}
179+
180+
// Create extractor on first frame
181+
if self.spriteExtractor == nil {
182+
self.spriteWidth = width
183+
self.spriteHeight = height
184+
let spriteSize = max(width, height)
185+
do {
186+
self.spriteExtractor = try SCSprite.extractor(withSpriteSize: Int32(spriteSize), qp: 26, outputPath: self.mbsOutputPath)
187+
} catch {
188+
self.isFailed = true
189+
return
190+
}
191+
}
192+
193+
guard self.spriteWidth == width && self.spriteHeight == height else {
194+
self.isFailed = true
195+
return
196+
}
197+
198+
// Allocate ARGB surface
199+
let bytesPerRow = width * 4
200+
let bufferSize = height * bytesPerRow
201+
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
202+
defer { buffer.deallocate() }
203+
memset(buffer, 0, bufferSize)
204+
205+
guard let duration = drawingBlock(AnimationCacheItemDrawingSurface(
206+
argb: buffer,
207+
width: width,
208+
height: height,
209+
bytesPerRow: bytesPerRow,
210+
length: bufferSize
211+
)) else {
212+
return
213+
}
214+
215+
// Convert ARGB → YUV planes
216+
let planes = convertARGBToYUVA420(
217+
argb: buffer,
218+
width: width,
219+
height: height,
220+
bytesPerRow: bytesPerRow
221+
)
222+
223+
// Feed to extractor
224+
do {
225+
try self.spriteExtractor?.addFrameY(
226+
planes.y, yStride: Int32(planes.yStride),
227+
cb: planes.cb, cbStride: Int32(planes.cbStride),
228+
cr: planes.cr, crStride: Int32(planes.crStride),
229+
alpha: planes.alpha, alphaStride: Int32(planes.alphaStride)
230+
)
231+
} catch {
232+
self.isFailed = true
233+
return
234+
}
235+
236+
self.frameDurations.append(duration)
237+
}
238+
}
239+
240+
func finish() {
241+
var result: CompressedResult?
242+
243+
self.lock.locked {
244+
if self.isFinished {
245+
return
246+
}
247+
self.isFinished = true
248+
249+
if self.isFailed || self.spriteExtractor == nil {
250+
return
251+
}
252+
253+
do {
254+
try self.spriteExtractor?.finalizeExtraction()
255+
} catch {
256+
self.isFailed = true
257+
return
258+
}
259+
260+
// Write metadata file: frame count + durations
261+
var metaData = Data()
262+
var frameCount = UInt32(self.frameDurations.count)
263+
metaData.append(Data(bytes: &frameCount, count: 4))
264+
for duration in self.frameDurations {
265+
var d = Float32(duration)
266+
metaData.append(Data(bytes: &d, count: 4))
267+
}
268+
do {
269+
try metaData.write(to: URL(fileURLWithPath: self.metaOutputPath))
270+
} catch {
271+
self.isFailed = true
272+
return
273+
}
274+
275+
result = CompressedResult(mbsPath: self.mbsOutputPath, metaPath: self.metaOutputPath)
276+
}
277+
278+
if !self.isFailed {
279+
self.completion(result)
280+
} else {
281+
let _ = try? FileManager.default.removeItem(atPath: self.mbsOutputPath)
282+
let _ = try? FileManager.default.removeItem(atPath: self.metaOutputPath)
283+
self.completion(nil)
284+
}
285+
}
286+
}

0 commit comments

Comments
 (0)