react-native-alpha-video-gpu 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +949 -2
  3. package/THIRD_PARTY_NOTICES.md +14 -0
  4. package/android/src/main/AndroidManifest.xml +9 -0
  5. package/android/src/main/cpp/CMakeLists.txt +41 -0
  6. package/android/src/main/cpp/alphavideogpu_jni.cpp +1168 -0
  7. package/android/src/main/cpp/astc_decomp.cpp +1550 -0
  8. package/android/src/main/cpp/astc_decomp.h +41 -0
  9. package/android/src/main/cpp/source_jni.cpp +242 -0
  10. package/android/src/main/cpp/source_jni.h +13 -0
  11. package/android/src/main/java/dev/mediacache/alphavideo/gpu/AlphaVideoGpuNative.java +43 -0
  12. package/android/src/main/java/dev/mediacache/alphavideo/gpu/AlphaVideoGpuReader.java +209 -0
  13. package/android/src/main/java/dev/mediacache/alphavideo/gpu/AlphaVideoGpuSource.java +328 -0
  14. package/android/src/main/java/dev/mediacache/alphavideo/gpu/AlphaVideoGpuView.java +2126 -0
  15. package/android/src/main/java/dev/mediacache/alphavideo/gpu/Fetcher.java +37 -0
  16. package/android/src/main/java/dev/mediacache/alphavideo/gpu/HttpFetcher.java +156 -0
  17. package/android/src/main/java/dev/mediacache/alphavideo/gpu/Ktx2Metadata.java +78 -0
  18. package/android/src/main/java/dev/mediacache/alphavideo/gpu/RangeSourceLink.java +54 -0
  19. package/android/src/main/java/dev/mediacache/alphavideo/gpu/ZstdSeekTable.java +120 -0
  20. package/android/src/rangesource/java/dev/mediacache/alphavideo/gpu/AppDataSource.java +50 -0
  21. package/ios/Sources/AlphaVideoGPU/AlphaVideoGpuSource.h +63 -0
  22. package/ios/Sources/AlphaVideoGPU/AlphaVideoGpuSource.mm +522 -0
  23. package/ios/Sources/AlphaVideoGPU/AlphaVideoGpuView.mm +2722 -0
  24. package/ios/Sources/AlphaVideoGPU/astc_decomp.cpp +1550 -0
  25. package/ios/Sources/AlphaVideoGPU/astc_decomp.h +41 -0
  26. package/ios/Sources/AlphaVideoGPU/include/AlphaVideoGPU.h +110 -0
  27. package/ios/Sources/AlphaVideoGPU/zstd_seek.h +250 -0
  28. package/package.json +77 -4
  29. package/react-native/android/build.gradle +92 -0
  30. package/react-native/android/src/main/AndroidManifest.xml +9 -0
  31. package/react-native/android/src/main/java/dev/mediacache/alphavideo/gpu/reactnative/AlphaVideoGpuPackage.java +26 -0
  32. package/react-native/android/src/main/java/dev/mediacache/alphavideo/gpu/reactnative/AlphaVideoGpuViewManager.java +187 -0
  33. package/react-native/android/src/main/java/dev/mediacache/alphavideo/gpu/reactnative/PlayerEventForwarder.java +107 -0
  34. package/react-native/android/src/newarch/java/dev/mediacache/alphavideo/gpu/reactnative/AlphaVideoGpuViewManagerSpec.java +23 -0
  35. package/react-native/android/src/norangesource/java/dev/mediacache/alphavideo/gpu/AppDataSource.java +15 -0
  36. package/react-native/android/src/oldarch/java/dev/mediacache/alphavideo/gpu/reactnative/AlphaVideoGpuViewManagerSpec.java +56 -0
  37. package/react-native/ios/RNAlphaVideoGpuViewComponentView.mm +176 -0
  38. package/react-native/ios/RNAlphaVideoGpuViewManager.mm +175 -0
  39. package/react-native/src/AlphaVideoGpuView.js +2 -0
  40. package/react-native/src/AlphaVideoGpuView.web.tsx +264 -0
  41. package/react-native/src/AlphaVideoGpuViewNativeComponent.ts +128 -0
  42. package/react-native/src/AlphaVideoGpuViewNativeComponent.web.ts +2 -0
  43. package/react-native/src/index.d.ts +95 -0
  44. package/react-native/src/index.js +162 -0
  45. package/react-native-alpha-video-gpu.podspec +37 -0
  46. package/react-native.config.js +12 -0
  47. package/third_party/licenses/Apache-2.0.txt +208 -0
  48. package/third_party/range-source/LICENSE +21 -0
  49. package/third_party/range-source/README.md +10 -0
  50. package/third_party/range-source/include/range_source.h +395 -0
  51. package/third_party/range-source/include/range_source_download.h +309 -0
  52. package/third_party/zstd/LICENSE +30 -0
  53. package/third_party/zstd/zstd.c +38717 -0
  54. package/third_party/zstd/zstd.h +2450 -0
@@ -0,0 +1,2722 @@
1
+ #import "AlphaVideoGPU.h"
2
+ #import "AlphaVideoGpuSource.h"
3
+ #import <TargetConditionals.h>
4
+ #import <sys/mman.h>
5
+ #import <sys/stat.h>
6
+ #import <fcntl.h>
7
+ #import <unistd.h>
8
+
9
+ // Third-party sources compiled into this file, with their own warnings left alone
10
+ #pragma clang diagnostic push
11
+ #pragma clang diagnostic ignored "-Weverything"
12
+
13
+ #define ZSTD_STATIC_LINKING_ONLY
14
+ #include "zstd/zstd.h"
15
+ #include "zstd/zstd.c"
16
+
17
+ // Software ASTC decoder, for the simulator
18
+ #include "astc_decomp.cpp"
19
+
20
+ #pragma clang diagnostic pop
21
+
22
+ #include "zstd_seek.h"
23
+
24
+ // How long a display tick spends catching up to a seek; the rest goes on over the next ticks
25
+ static const CFTimeInterval kSeekSkipSliceSeconds = 0.008;
26
+
27
+ // GPU slots when streaming with the ram strategy, or an uncompressed file: frames are uploaded
28
+ // as they're shown, and the previous ones may still be drawing
29
+ static const NSInteger kRamRingSize = 4;
30
+
31
+ // The store's cap when maxStorageBytes isn't set
32
+ static const int64_t kAVGDefaultMaxStorageBytes = 512LL * 1024 * 1024;
33
+ // Bytes of a level read from a reader at a time
34
+ static const size_t kAVGReaderChunkBytes = 256 * 1024;
35
+ // Bytes read first from a reader for the header and metadata, which most files fit in
36
+ static const int64_t kAVGHeaderGuessBytes = 16 * 1024;
37
+
38
+ static_assert(AVGSourceReaderPending == AVG_ZSTD_READ_PENDING, "a pending read means the same to both");
39
+
40
+ #define AVG_VLOG(fmt, ...) do { if (self.verbose) NSLog(@"AlphaVideoGpuView: " fmt, ##__VA_ARGS__); } while (0)
41
+
42
+ static void AVGWarnWithoutAddOn(void)
43
+ {
44
+ static dispatch_once_t once;
45
+ dispatch_once(&once, ^{
46
+ NSLog(@"[AlphaVideoGPU] range-source isn't installed (the RangeSource SwiftPM product, the alpha-video-gpu pod's "
47
+ @"Source subspec, or for React Native npm i react-native-range-source): URLs are downloaded whole for each "
48
+ @"play, without range requests, storage, maxStorageBytes or cacheKey");
49
+ });
50
+ }
51
+
52
+ #pragma mark - Source resolution
53
+
54
+ static BOOL AVGIsRemote(NSString *uri) {
55
+ return [uri hasPrefix:@"https://"] || [uri hasPrefix:@"http://"];
56
+ }
57
+
58
+ /** The file an asset:// (in the app bundle), file:// or absolute-path source names. */
59
+ static NSString *AVGLocalPath(NSString *uri, NSString **error) {
60
+ NSString *path = nil;
61
+ if ([uri hasPrefix:@"asset://"]) {
62
+ NSString *relative = [uri substringFromIndex:@"asset://".length];
63
+ relative = [relative stringByRemovingPercentEncoding] ?: relative;
64
+ while ([relative hasPrefix:@"/"]) {
65
+ relative = [relative substringFromIndex:1];
66
+ }
67
+ if (relative.length > 0) {
68
+ path = [[NSBundle mainBundle].resourcePath stringByAppendingPathComponent:relative];
69
+ }
70
+ } else if ([uri hasPrefix:@"file://"]) {
71
+ NSURL *url = [NSURL URLWithString:uri];
72
+ path = url.isFileURL ? url.path : [uri substringFromIndex:@"file://".length];
73
+ } else if ([uri hasPrefix:@"/"]) {
74
+ path = uri;
75
+ }
76
+
77
+ if (!path) {
78
+ *error = [NSString stringWithFormat:@"Unsupported source '%@'. Use https://, file://, an absolute path or asset://", uri];
79
+ return nil;
80
+ }
81
+ if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
82
+ *error = [NSString stringWithFormat:@"File not found: %@", uri];
83
+ return nil;
84
+ }
85
+ return path;
86
+ }
87
+
88
+ /** A level's bytes through a reader, for AVGZstdStream's read function. */
89
+ typedef struct {
90
+ void *reader; // the view's AVGSourceReader, which it holds while the stream reads
91
+ uint64_t levelOffset;
92
+ } AVGLevelInput;
93
+
94
+ static int64_t AVGReadLevel(void *context, uint64_t offset, uint8_t *buffer, size_t length) {
95
+ AVGLevelInput *input = (AVGLevelInput *)context;
96
+ return [(__bridge AVGSourceReader *)input->reader tryReadAt:(int64_t)(input->levelOffset + offset)
97
+ into:buffer
98
+ length:length];
99
+ }
100
+
101
+ @interface AlphaVideoGpuView () <MTKViewDelegate>
102
+
103
+ @property (nonatomic, strong) id<MTLCommandQueue> commandQueue;
104
+ @property (nonatomic, strong) id<MTLBuffer> vertexBuffer;
105
+
106
+ @property (nonatomic, assign) NSInteger currentFrame;
107
+ @property (nonatomic, assign) NSInteger totalFrames;
108
+ @property (nonatomic, assign) CGFloat frameRate;
109
+ @property (nonatomic, assign) BOOL isPlaying;
110
+ @property (nonatomic, assign) CFTimeInterval frameInterval;
111
+
112
+ @property (nonatomic, assign) NSInteger frameWidth;
113
+ @property (nonatomic, assign) NSInteger frameHeight;
114
+
115
+ @property (nonatomic, strong) CADisplayLink *displayLink;
116
+ @property (nonatomic, assign) CFTimeInterval lastFrameTime;
117
+
118
+ @end
119
+
120
+ /** Frame rate from KTXanimData (per-frame duration / timescale) in a key/value data block, or 0. */
121
+ static double AVGAnimFrameRate(const uint8_t *kvd, NSUInteger length) {
122
+ NSUInteger pos = 0;
123
+ while (pos + 4 <= length) {
124
+ uint32_t entryLength = avg_read_u32le(kvd + pos);
125
+ NSUInteger entryStart = pos + 4;
126
+ if (entryLength == 0 || entryStart + entryLength > length) break;
127
+ NSUInteger keyEnd = entryStart;
128
+ while (keyEnd < entryStart + entryLength && kvd[keyEnd] != 0) keyEnd++;
129
+ static const char kAnimKey[] = "KTXanimData";
130
+ if (keyEnd - entryStart == sizeof(kAnimKey) - 1 && memcmp(kvd + entryStart, kAnimKey, keyEnd - entryStart) == 0 &&
131
+ entryStart + entryLength >= keyEnd + 1 + 12) {
132
+ uint32_t duration = avg_read_u32le(kvd + keyEnd + 1);
133
+ uint32_t timescale = avg_read_u32le(kvd + keyEnd + 5);
134
+ return duration > 0 && timescale > 0 ? (double)timescale / duration : 0;
135
+ }
136
+ pos = entryStart + entryLength + (4 - entryLength % 4) % 4;
137
+ }
138
+ return 0;
139
+ }
140
+
141
+ /**
142
+ * Frame size, frame count and vkFormat from the KTX2 header, and the frame rate from KTXanimData
143
+ * (30 without it). Both are standard KTX 2.0; texenc writes no other metadata.
144
+ */
145
+ static NSDictionary *AVGKTX2Metadata(const uint8_t *header, const uint8_t *kvd, NSUInteger kvdLength) {
146
+ double frameRate = kvd ? AVGAnimFrameRate(kvd, kvdLength) : 0;
147
+ return @{
148
+ @"vkFormat": @(avg_read_u32le(header + 12)),
149
+ @"frameWidth": @(avg_read_u32le(header + 20)),
150
+ @"frameHeight": @(avg_read_u32le(header + 24)),
151
+ @"frameCount": @(avg_read_u32le(header + 32)),
152
+ @"frameRate": @(frameRate > 0 ? frameRate : 30.0),
153
+ };
154
+ }
155
+
156
+ /** ASTC (VkFormat 157-184, either colour space) in an array texture: the only files that play. */
157
+ static BOOL AVGIsASTCArray(NSDictionary *metadata) {
158
+ uint32_t vkFormat = [metadata[@"vkFormat"] unsignedIntValue];
159
+ return vkFormat >= 157 && vkFormat <= 184 && [metadata[@"frameCount"] unsignedIntValue] >= 1;
160
+ }
161
+
162
+ NSString *const AlphaVideoGpuErrorNoSourceModule = @"NO_SOURCE_MODULE";
163
+
164
+ @implementation AlphaVideoGpuView {
165
+ MTLPixelFormat _metalPixelFormat;
166
+ BOOL _useCompressedFormat;
167
+ id<MTLSamplerState> _samplerState;
168
+ NSString *_sourceUri;
169
+ // The local file the current source resolved to (a bundle file or a path)
170
+ NSString *_localPath;
171
+ // What a URL or data source is read through, while it plays
172
+ AVGSourceReader *_reader;
173
+ // The headers and data source the current source was opened with
174
+ NSDictionary<NSString *, NSString *> *_playingHeaders;
175
+ NSString *_playingDataSource;
176
+ // A whole file read from _reader, which _mmapData points into in place of a mapping
177
+ NSData *_heldFileData;
178
+ // The zstd stream's input from _reader, and the buffer it reads into
179
+ AVGLevelInput _levelInput;
180
+ NSMutableData *_streamChunk;
181
+ // Streaming stopped on a read or decompression error
182
+ BOOL _streamFailed;
183
+ // Bumped for every new source, so a slow download for an old one is dropped
184
+ NSUInteger _sourceGeneration;
185
+ // The cache key the loaded/loading source belongs to. Compared against self.cacheKey
186
+ // to tell a re-signed URL for the same object apart from a new asset.
187
+ NSString *_loadedCacheKey;
188
+ BOOL _isSourceLoaded;
189
+ BOOL _isSourceLoading;
190
+ BOOL _isDestroyed;
191
+
192
+ id<MTLTexture> _textureArray;
193
+ NSInteger _textureArrayLayerCount;
194
+ id<MTLRenderPipelineState> _arrayPipelineState;
195
+ id<MTLBuffer> _arrayUniformBuffer;
196
+
197
+ // Streaming ring buffer support (for efficient memory usage)
198
+ BOOL _streamingRingBufferMode;
199
+ NSInteger _streamingRingBufferSize;
200
+ NSMutableArray<NSNumber *> *_ringSlots; // Tracks which virtual frame is in each GPU slot
201
+ NSMutableDictionary<NSNumber *, NSData *> *_frameCache; // CPU frame cache
202
+ NSInteger _decoderPosition; // Current decoder frame position
203
+ NSInteger _decoderLoopIteration; // Which loop iteration the decoder is on
204
+ NSInteger _playbackLoopIteration; // Which loop iteration playback is on
205
+ BOOL _useVramStrategy; // True for VRAM-heavy mode
206
+ // Per pass, for tuning streamingPrefetchAhead: the fewest frames decoded after the shown one,
207
+ // and how often playback waited for the decoder
208
+ NSInteger _fewestAhead;
209
+ NSInteger _stalls;
210
+ BOOL _stalled;
211
+
212
+ // The shown frame changed (a load or a seek) and is drawn once decoded, playing or not
213
+ BOOL _frameOwed;
214
+ // A clip that doesn't loop has reached its last frame; playing again starts it over
215
+ BOOL _ended;
216
+
217
+ // Streaming decompression state
218
+ ZSTD_DStream *_dstream;
219
+ NSData *_compressedData;
220
+ // Reads _compressedData through _dstream
221
+ AVGZstdStream _zstdStream;
222
+ // Empty when the level is one zstd stream
223
+ AVGZstdSeekTable _seekTable;
224
+ // Decoded bytes still to drop before _decoderPosition: a seek catching up
225
+ uint64_t _pendingSkipBytes;
226
+ NSMutableData *_frameAccumulator;
227
+ uint32_t _streamBytesPerLayer;
228
+ uint64_t _streamLevel0Offset;
229
+
230
+ // Uncompressed streaming (mmap)
231
+ int _mmapFd;
232
+ void *_mmapData;
233
+ size_t _mmapSize;
234
+ BOOL _isUncompressedStreaming;
235
+
236
+ // Decoded RGBA cache for simulator ASTC decompression (background thread)
237
+ NSMutableDictionary<NSNumber *, NSData *> *_decodedRgbaCache;
238
+ NSInteger _uncompressedDecoderPosition;
239
+ dispatch_queue_t _astcDecodeQueue;
240
+ BOOL _uncompressedDecodeQueued; // main thread only
241
+
242
+ // ASTC block dimensions (derived from vkFormat, default 4x4)
243
+ uint32_t _astcBlockWidth;
244
+ uint32_t _astcBlockHeight;
245
+
246
+ // Staging buffers for async blit uploads (double-buffered)
247
+ id<MTLBuffer> _stagingBuffers[2];
248
+ NSInteger _currentStagingBuffer;
249
+ id<MTLCommandQueue> _blitCommandQueue;
250
+ }
251
+
252
+ - (instancetype)initWithFrame:(CGRect)frame
253
+ {
254
+ AVG_VLOG(@"initWithFrame %@", NSStringFromCGRect(frame));
255
+
256
+ self = [super initWithFrame:frame];
257
+ if (self) {
258
+ self.device = MTLCreateSystemDefaultDevice();
259
+ AVG_VLOG(@"Metal device=%@", self.device.name);
260
+ self.commandQueue = [self.device newCommandQueue];
261
+ self.delegate = self;
262
+
263
+ // Configure MTKView to render on demand only (we control timing via CADisplayLink)
264
+ self.paused = YES;
265
+ self.enableSetNeedsDisplay = YES;
266
+
267
+ // CRITICAL: Set up transparency
268
+ self.opaque = NO;
269
+ self.backgroundColor = [UIColor clearColor];
270
+ self.layer.opaque = NO;
271
+ self.clearColor = MTLClearColorMake(0, 0, 0, 0);
272
+
273
+ // Initialize with default values
274
+ self.currentFrame = 0;
275
+ self.totalFrames = 0;
276
+ self.frameRate = 30.0;
277
+ self.isPlaying = NO;
278
+ self.loop = YES;
279
+ self.playbackSpeed = 1.0;
280
+ self.frameInterval = 1.0 / self.frameRate;
281
+ self.enableFrameEvents = NO; // Disabled by default for performance
282
+ _isSourceLoaded = NO;
283
+ _isSourceLoading = NO;
284
+ _isDestroyed = NO;
285
+
286
+ // Streaming configuration defaults (match Android)
287
+ self.streamingPrefetchAhead = 8; // Frames to decode ahead
288
+ self.memoryStrategy = @"vram"; // vram, ram
289
+
290
+ // Initialize streaming state
291
+ _streamingRingBufferMode = NO;
292
+ _streamingRingBufferSize = 0;
293
+ _ringSlots = nil;
294
+ _frameCache = nil;
295
+ _decoderPosition = 0;
296
+ _decoderLoopIteration = 0;
297
+ _playbackLoopIteration = 0;
298
+ _useVramStrategy = NO;
299
+ _dstream = NULL;
300
+ _compressedData = nil;
301
+ memset(&_zstdStream, 0, sizeof(_zstdStream));
302
+ memset(&_seekTable, 0, sizeof(_seekTable));
303
+ _pendingSkipBytes = 0;
304
+ _frameAccumulator = nil;
305
+ _streamBytesPerLayer = 0;
306
+ _streamLevel0Offset = 0;
307
+ _mmapFd = -1;
308
+ _mmapData = NULL;
309
+ _mmapSize = 0;
310
+ _isUncompressedStreaming = NO;
311
+ _decodedRgbaCache = nil;
312
+ _uncompressedDecoderPosition = 0;
313
+ _astcDecodeQueue = dispatch_queue_create("dev.mediacache.alphavideo.gpu.astcdecode", DISPATCH_QUEUE_SERIAL);
314
+ _astcBlockWidth = 4;
315
+ _astcBlockHeight = 4;
316
+
317
+ // Detect and cache optimal texture format
318
+ [self detectAndCacheTextureFormat];
319
+
320
+ // Default values
321
+ self.autoPlay = YES;
322
+ self.stream = YES;
323
+ self.storage = nil;
324
+
325
+ [self setupMetal];
326
+ [self setupVertexData];
327
+ }
328
+ return self;
329
+ }
330
+
331
+ - (void)detectAndCacheTextureFormat
332
+ {
333
+ // Check if running on simulator
334
+ #if TARGET_OS_SIMULATOR
335
+ AVG_VLOG(@"Running on simulator - decoding ASTC to RGBA in software");
336
+ _metalPixelFormat = MTLPixelFormatRGBA8Unorm;
337
+ _useCompressedFormat = NO;
338
+ #else
339
+ // Check for ASTC support (all real iOS devices with Metal support ASTC)
340
+ if (@available(iOS 8.0, *)) {
341
+ // Check device capabilities - ASTC is supported on A8 and later
342
+ if ([self.device supportsFamily:MTLGPUFamilyApple2]) {
343
+ AVG_VLOG(@"The GPU decodes ASTC");
344
+ _metalPixelFormat = MTLPixelFormatASTC_4x4_LDR;
345
+ _useCompressedFormat = YES;
346
+ return;
347
+ }
348
+ }
349
+
350
+ // Fallback to RGBA32 for older devices
351
+ AVG_VLOG(@"No GPU ASTC support - decoding ASTC to RGBA in software");
352
+ _metalPixelFormat = MTLPixelFormatRGBA8Unorm;
353
+ _useCompressedFormat = NO;
354
+ #endif
355
+ }
356
+
357
+ /**
358
+ * Map a KTX2 vkFormat to the corresponding MTLPixelFormat and ASTC block dimensions.
359
+ * Supports all 14 ASTC block sizes (UNORM/SRGB pairs, VkFormat 157–184).
360
+ * Updates _metalPixelFormat, _astcBlockWidth, _astcBlockHeight.
361
+ * Returns YES if the vkFormat is a recognized ASTC format.
362
+ */
363
+ - (BOOL)configureASTCFormatFromVkFormat:(uint32_t)vkFormat
364
+ {
365
+ // All ASTC VkFormats come in UNORM/SRGB pairs.
366
+ // Both map to the LDR MTLPixelFormat, as on Android and web: the encoder stores premultiplied
367
+ // sRGB-encoded bytes that must reach the drawable unchanged, and an _sRGB format would
368
+ // linearise them on sampling with nothing converting back.
369
+ struct ASTCFormatInfo {
370
+ uint32_t vkFormatUnorm;
371
+ uint32_t vkFormatSrgb;
372
+ MTLPixelFormat pixelFormat;
373
+ uint32_t blockWidth;
374
+ uint32_t blockHeight;
375
+ };
376
+
377
+ static const ASTCFormatInfo formats[] = {
378
+ { 157, 158, MTLPixelFormatASTC_4x4_LDR, 4, 4 },
379
+ { 159, 160, MTLPixelFormatASTC_5x4_LDR, 5, 4 },
380
+ { 161, 162, MTLPixelFormatASTC_5x5_LDR, 5, 5 },
381
+ { 163, 164, MTLPixelFormatASTC_6x5_LDR, 6, 5 },
382
+ { 165, 166, MTLPixelFormatASTC_6x6_LDR, 6, 6 },
383
+ { 167, 168, MTLPixelFormatASTC_8x5_LDR, 8, 5 },
384
+ { 169, 170, MTLPixelFormatASTC_8x6_LDR, 8, 6 },
385
+ { 171, 172, MTLPixelFormatASTC_8x8_LDR, 8, 8 },
386
+ { 173, 174, MTLPixelFormatASTC_10x5_LDR, 10, 5 },
387
+ { 175, 176, MTLPixelFormatASTC_10x6_LDR, 10, 6 },
388
+ { 177, 178, MTLPixelFormatASTC_10x8_LDR, 10, 8 },
389
+ { 179, 180, MTLPixelFormatASTC_10x10_LDR, 10, 10 },
390
+ { 181, 182, MTLPixelFormatASTC_12x10_LDR, 12, 10 },
391
+ { 183, 184, MTLPixelFormatASTC_12x12_LDR, 12, 12 },
392
+ };
393
+
394
+ for (const auto &fmt : formats) {
395
+ if (vkFormat == fmt.vkFormatUnorm || vkFormat == fmt.vkFormatSrgb) {
396
+ // Always set block dimensions (needed for software decoding on simulator)
397
+ _astcBlockWidth = fmt.blockWidth;
398
+ _astcBlockHeight = fmt.blockHeight;
399
+
400
+ // Only update pixel format if using compressed format (real device)
401
+ // On simulator, _useCompressedFormat is NO and _metalPixelFormat is RGBA8Unorm
402
+ #if !TARGET_OS_SIMULATOR
403
+ _metalPixelFormat = fmt.pixelFormat;
404
+ #endif
405
+ AVG_VLOG(@"ASTC format: %ux%u (VkFormat=%u, MTLPixelFormat=%lu, useCompressed=%d)",
406
+ fmt.blockWidth, fmt.blockHeight, vkFormat, (unsigned long)_metalPixelFormat, _useCompressedFormat);
407
+ return YES;
408
+ }
409
+ }
410
+
411
+ AVG_VLOG(@"Unknown ASTC VkFormat: %u, falling back to 4x4", vkFormat);
412
+ #if !TARGET_OS_SIMULATOR
413
+ _metalPixelFormat = MTLPixelFormatASTC_4x4_LDR;
414
+ #endif
415
+ _astcBlockWidth = 4;
416
+ _astcBlockHeight = 4;
417
+ return NO;
418
+ }
419
+
420
+ /**
421
+ * Calculate bytes per layer for the current ASTC block size.
422
+ * All ASTC blocks are 16 bytes (128 bits) regardless of block dimensions.
423
+ */
424
+ - (uint32_t)astcBytesPerLayerForWidth:(uint32_t)width height:(uint32_t)height
425
+ {
426
+ uint32_t blocksX = (width + _astcBlockWidth - 1) / _astcBlockWidth;
427
+ uint32_t blocksY = (height + _astcBlockHeight - 1) / _astcBlockHeight;
428
+ return blocksX * blocksY * 16;
429
+ }
430
+
431
+ - (void)setSource:(NSString *)source
432
+ {
433
+ AVG_VLOG(@"setSource %@", source);
434
+ NSString *uri = source.length > 0 ? source : nil;
435
+
436
+ if (!uri) {
437
+ _source = nil;
438
+ _sourceUri = nil;
439
+ _localPath = nil;
440
+ _sourceGeneration++;
441
+ [self cleanup];
442
+ return;
443
+ }
444
+
445
+ // Check if source changed
446
+ BOOL sameReads = [self readsAsPlaying];
447
+ if ([_sourceUri isEqualToString:uri] && sameReads && (_isSourceLoaded || _isSourceLoading)) {
448
+ AVG_VLOG(@"Source unchanged and already loaded/loading, skipping");
449
+ return;
450
+ }
451
+
452
+ // A presigned URL is re-signed every time the app lists its media, so the URL changes
453
+ // while still addressing the same object. When the cache key matches what is already
454
+ // on screen this is a credential refresh, not a new asset, and reloading would
455
+ // interrupt a loop that is supposed to run until unmount.
456
+ if (self.cacheKey.length > 0 && [self.cacheKey isEqualToString:_loadedCacheKey] && sameReads
457
+ && (_isSourceLoaded || _isSourceLoading)) {
458
+ AVG_VLOG(@"Same cacheKey (%@), ignoring URL change", self.cacheKey);
459
+ _source = [uri copy];
460
+ _sourceUri = [uri copy];
461
+ return;
462
+ }
463
+
464
+ // Frees the previous source: its decoder, mapping or reader, and frames
465
+ [self cleanup];
466
+ _source = [uri copy];
467
+ _sourceUri = [uri copy];
468
+ _loadedCacheKey = [self.cacheKey copy];
469
+ _isSourceLoaded = NO;
470
+ _isSourceLoading = YES;
471
+ _isDestroyed = NO;
472
+ self.currentFrame = 0;
473
+ _ended = NO;
474
+ _frameOwed = NO;
475
+
476
+ _localPath = nil;
477
+ [self notifyStateChange:@"loading"];
478
+ [self resolveAndLoadSource];
479
+ }
480
+
481
+ /** Whether the headers and data source set are the ones the current source was opened with. */
482
+ - (BOOL)readsAsPlaying
483
+ {
484
+ NSString *dataSource = self.dataSource.length > 0 ? self.dataSource : nil;
485
+ return [self.headers ?: @{} isEqualToDictionary:_playingHeaders ?: @{}]
486
+ && (dataSource == _playingDataSource || [dataSource isEqualToString:_playingDataSource]);
487
+ }
488
+
489
+ - (void)setStorage:(NSString *)storage
490
+ {
491
+ _storage = [@[ @"disk", @"memory", @"none" ] containsObject:storage] ? [storage copy] : @"auto";
492
+ }
493
+
494
+ /**
495
+ * Opens _sourceUri off the main thread, then loads it: a URL or a data source through a reader,
496
+ * anything else as a local file. Starts on the next main-queue turn, so that properties set in
497
+ * the same turn as source (React Native's, in any order) apply to it.
498
+ */
499
+ - (void)resolveAndLoadSource
500
+ {
501
+ NSUInteger generation = ++_sourceGeneration;
502
+ dispatch_async(dispatch_get_main_queue(), ^{
503
+ if (generation != self->_sourceGeneration) return;
504
+ if (self->_isDestroyed) {
505
+ self->_isSourceLoading = NO;
506
+ return;
507
+ }
508
+ [self openSource:generation];
509
+ });
510
+ }
511
+
512
+ - (void)openSource:(NSUInteger)generation
513
+ {
514
+ NSString *uri = _sourceUri;
515
+ _loadedCacheKey = [self.cacheKey copy];
516
+ _playingHeaders = [self.headers copy] ?: @{};
517
+ _playingDataSource = self.dataSource.length > 0 ? [self.dataSource copy] : nil;
518
+ NSString *dataSourceName = _playingDataSource;
519
+ BOOL readsRanges = dataSourceName != nil || AVGIsRemote(uri);
520
+ if (readsRanges && !AVGRangeSourceAvailable()) {
521
+ if (dataSourceName) {
522
+ [self notifyError:[NSString stringWithFormat:@"The data source \"%@\" needs the range-source add-on (the "
523
+ @"RangeSource SwiftPM product, the alpha-video-gpu pod's Source "
524
+ @"subspec, or for React Native react-native-range-source), which "
525
+ @"the app doesn't include", dataSourceName]
526
+ code:AlphaVideoGpuErrorNoSourceModule];
527
+ return;
528
+ }
529
+ AVGWarnWithoutAddOn();
530
+ }
531
+ NSDictionary<NSString *, NSString *> *headers = _playingHeaders;
532
+ // auto: the disk for URLs; a data source's caching is its own
533
+ NSString *storage = ![self.storage isEqualToString:@"auto"] ? self.storage : dataSourceName ? @"none" : @"disk";
534
+ NSString *key = self.cacheKey.length > 0 ? self.cacheKey : uri;
535
+ int64_t maxStorageBytes = self.maxStorageBytes > 0 ? self.maxStorageBytes : kAVGDefaultMaxStorageBytes;
536
+ AVG_VLOG(@"Resolving source %@", uri);
537
+
538
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
539
+ NSString *error = nil;
540
+ NSString *path = nil;
541
+ AVGSourceReader *reader = nil;
542
+ if (readsRanges) {
543
+ NSURL *url = [NSURL URLWithString:uri];
544
+ reader = url ? [AVGSourceReader openURL:url
545
+ dataSource:dataSourceName
546
+ headers:headers
547
+ storage:storage
548
+ key:key
549
+ maxStorageBytes:maxStorageBytes
550
+ error:&error]
551
+ : nil;
552
+ if (!reader) error = [NSString stringWithFormat:@"Loading %@ failed: %@", uri, error ?: @"not a URL"];
553
+ } else {
554
+ path = AVGLocalPath(uri, &error);
555
+ }
556
+ dispatch_async(dispatch_get_main_queue(), ^{
557
+ if (generation != self->_sourceGeneration) {
558
+ [reader close];
559
+ return;
560
+ }
561
+ if (self->_isDestroyed) {
562
+ [reader close];
563
+ self->_isSourceLoading = NO;
564
+ return;
565
+ }
566
+ if (reader) {
567
+ AVG_VLOG(@"Opened %@ (%lld bytes, storage %@)", uri, reader.size, storage);
568
+ self->_reader = reader;
569
+ [self loadKTX2FromReader:reader];
570
+ } else if (path) {
571
+ self->_localPath = path;
572
+ [self loadKTX2FromFile:path];
573
+ } else {
574
+ [self notifyError:error];
575
+ }
576
+ });
577
+ });
578
+ }
579
+
580
+ - (void)notifyStateChange:(NSString *)state
581
+ {
582
+ if (self.onStateChange) self.onStateChange(state);
583
+ }
584
+
585
+ - (void)notifyLoad
586
+ {
587
+ // Every load path reports here, once it can draw; show the first frame even before playing
588
+ _frameOwed = YES;
589
+ [self updateDisplayLink];
590
+ if (self.onLoad) self.onLoad(self.frameWidth, self.frameHeight, self.totalFrames, self.frameRate);
591
+ }
592
+
593
+ - (void)notifyFrameUpdate
594
+ {
595
+ if (self.onFrameUpdate) self.onFrameUpdate(self.currentFrame, self.totalFrames);
596
+ }
597
+
598
+ - (void)notifyFinished
599
+ {
600
+ if (self.onFinished) self.onFinished();
601
+ }
602
+
603
+ - (void)notifyError:(NSString *)error
604
+ {
605
+ [self notifyError:error code:nil];
606
+ }
607
+
608
+ - (void)notifyError:(NSString *)error code:(nullable NSString *)code
609
+ {
610
+ _isSourceLoading = NO;
611
+ NSLog(@"AlphaVideoGpuView: Error: %@", error);
612
+ if (self.onError) self.onError(error ?: @"Unknown error", code);
613
+ }
614
+
615
+ - (void)setupMetal
616
+ {
617
+ // Create vertex shader
618
+ NSString *vertexShaderSource = @"#include <metal_stdlib>\n"
619
+ "using namespace metal;\n"
620
+ "struct VertexOut {\n"
621
+ " float4 position [[position]];\n"
622
+ " float2 texCoord;\n"
623
+ "};\n"
624
+ "vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) {\n"
625
+ " VertexOut out;\n"
626
+ " const float2 positions[6] = {\n"
627
+ " float2(-1.0, -1.0), float2(1.0, -1.0), float2(-1.0, 1.0),\n"
628
+ " float2(-1.0, 1.0), float2(1.0, -1.0), float2(1.0, 1.0)\n"
629
+ " };\n"
630
+ " const float2 texCoords[6] = {\n"
631
+ " float2(0.0, 1.0), float2(1.0, 1.0), float2(0.0, 0.0),\n"
632
+ " float2(0.0, 0.0), float2(1.0, 1.0), float2(1.0, 0.0)\n"
633
+ " };\n"
634
+ " out.position = float4(positions[vertexID], 0.0, 1.0);\n"
635
+ " out.texCoord = texCoords[vertexID];\n"
636
+ " return out;\n"
637
+ "}";
638
+
639
+ AVG_VLOG(@"Compiling Metal shaders...");
640
+
641
+ NSError *error = nil;
642
+ id<MTLLibrary> vertexLibrary = [self.device newLibraryWithSource:vertexShaderSource options:nil error:&error];
643
+ if (error) {
644
+ NSLog(@"AlphaVideoGpuView: Vertex shader compilation error: %@", error);
645
+ }
646
+ id<MTLFunction> vertexFunction = [vertexLibrary newFunctionWithName:@"vertex_main"];
647
+ if (!vertexFunction) {
648
+ NSLog(@"AlphaVideoGpuView: Failed to get vertex_main function!");
649
+ }
650
+
651
+ // Each frame is a layer of a texture array
652
+ NSString *arrayFragmentShaderSource = @"#include <metal_stdlib>\n"
653
+ "using namespace metal;\n"
654
+ "struct VertexOut {\n"
655
+ " float4 position [[position]];\n"
656
+ " float2 texCoord;\n"
657
+ "};\n"
658
+ "struct ArrayUniforms {\n"
659
+ " float layer;\n"
660
+ "};\n"
661
+ "fragment float4 fragment_array(VertexOut in [[stage_in]],\n"
662
+ " texture2d_array<float> tex [[texture(0)]],\n"
663
+ " sampler s [[sampler(0)]],\n"
664
+ " constant ArrayUniforms &uniforms [[buffer(0)]]) {\n"
665
+ " return tex.sample(s, in.texCoord, uint(uniforms.layer));\n"
666
+ "}";
667
+
668
+ error = nil;
669
+ id<MTLLibrary> arrayFragmentLibrary = [self.device newLibraryWithSource:arrayFragmentShaderSource options:nil error:&error];
670
+ if (error) {
671
+ NSLog(@"AlphaVideoGpuView: Array fragment shader compilation error: %@", error);
672
+ }
673
+ id<MTLFunction> arrayFragmentFunction = [arrayFragmentLibrary newFunctionWithName:@"fragment_array"];
674
+
675
+ if (arrayFragmentFunction) {
676
+ MTLRenderPipelineDescriptor *arrayPipelineDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
677
+ arrayPipelineDescriptor.vertexFunction = vertexFunction;
678
+ arrayPipelineDescriptor.fragmentFunction = arrayFragmentFunction;
679
+ arrayPipelineDescriptor.colorAttachments[0].pixelFormat = self.colorPixelFormat;
680
+ arrayPipelineDescriptor.colorAttachments[0].blendingEnabled = YES;
681
+ arrayPipelineDescriptor.colorAttachments[0].rgbBlendOperation = MTLBlendOperationAdd;
682
+ arrayPipelineDescriptor.colorAttachments[0].alphaBlendOperation = MTLBlendOperationAdd;
683
+ arrayPipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = MTLBlendFactorOne;
684
+ arrayPipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = MTLBlendFactorOne;
685
+ arrayPipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = MTLBlendFactorOneMinusSourceAlpha;
686
+ arrayPipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = MTLBlendFactorOneMinusSourceAlpha;
687
+
688
+ error = nil;
689
+ _arrayPipelineState = [self.device newRenderPipelineStateWithDescriptor:arrayPipelineDescriptor error:&error];
690
+ if (error) {
691
+ NSLog(@"AlphaVideoGpuView: Failed to create array pipeline state: %@", error);
692
+ } else {
693
+ AVG_VLOG(@"Array pipeline state created successfully");
694
+ }
695
+ }
696
+
697
+ // Create uniform buffer for texture array (just layer index)
698
+ _arrayUniformBuffer = [self.device newBufferWithLength:16 options:MTLResourceStorageModeShared];
699
+ }
700
+
701
+ - (void)setupVertexData
702
+ {
703
+ // Create sampler state
704
+ MTLSamplerDescriptor *samplerDescriptor = [[MTLSamplerDescriptor alloc] init];
705
+ samplerDescriptor.minFilter = MTLSamplerMinMagFilterLinear;
706
+ samplerDescriptor.magFilter = MTLSamplerMinMagFilterLinear;
707
+ samplerDescriptor.sAddressMode = MTLSamplerAddressModeClampToEdge;
708
+ samplerDescriptor.tAddressMode = MTLSamplerAddressModeClampToEdge;
709
+ _samplerState = [self.device newSamplerStateWithDescriptor:samplerDescriptor];
710
+ }
711
+
712
+ /** Reads the frame metadata from data holding the file through its key/value data; nil if it isn't KTX2. */
713
+ - (NSDictionary *)extractKTX2Metadata:(NSData *)ktx2Data
714
+ {
715
+ if (ktx2Data.length < 80) return nil;
716
+ const uint8_t *bytes = (const uint8_t *)ktx2Data.bytes;
717
+ if (bytes[0] != 0xAB || bytes[1] != 0x4B || bytes[2] != 0x54 || bytes[3] != 0x58) return nil;
718
+
719
+ uint32_t kvdOffset = avg_read_u32le(bytes + 56);
720
+ uint32_t kvdLength = avg_read_u32le(bytes + 60);
721
+ BOOL kvdInRange = kvdLength > 0 && (uint64_t)kvdOffset + kvdLength <= ktx2Data.length;
722
+ return AVGKTX2Metadata(bytes, kvdInRange ? bytes + kvdOffset : NULL, kvdInRange ? kvdLength : 0);
723
+ }
724
+
725
+ - (void)loadKTX2FromFile:(NSString *)path
726
+ {
727
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
728
+ // With stream, an uncompressed texture array is mmap-streamed rather than loaded into memory
729
+ if ([self checkAndPlayUncompressedStreaming:path]) {
730
+ AVG_VLOG(@"Using uncompressed streaming mode");
731
+ return;
732
+ }
733
+
734
+ // Fall back to loading entire file
735
+ NSData *fileData = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:nil];
736
+ if (!fileData) {
737
+ dispatch_async(dispatch_get_main_queue(), ^{
738
+ [self notifyError:@"Failed to load file"];
739
+ });
740
+ return;
741
+ }
742
+
743
+ NSDictionary *metadata = [self extractKTX2Metadata:fileData];
744
+ if (!metadata) {
745
+ dispatch_async(dispatch_get_main_queue(), ^{
746
+ [self notifyError:@"Not a KTX2 file"];
747
+ });
748
+ return;
749
+ }
750
+
751
+ // Continue on main thread with parsed metadata
752
+ dispatch_async(dispatch_get_main_queue(), ^{
753
+ [self applyMetadataAndLoadTextures:metadata sourcePath:path fileData:nil reader:nil];
754
+ });
755
+ });
756
+ }
757
+
758
+ /**
759
+ * Loads from a reader: the header and metadata first, then a streamed zstd level as it plays, or
760
+ * anything else read whole into memory.
761
+ */
762
+ - (void)loadKTX2FromReader:(AVGSourceReader *)reader
763
+ {
764
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
765
+ NSString *error = nil;
766
+ NSData *fileData = [self readHeaderFrom:reader error:&error];
767
+ NSDictionary *metadata = fileData ? [self extractKTX2Metadata:fileData] : nil;
768
+ if (fileData && !metadata) {
769
+ error = @"Not a KTX2 file";
770
+ }
771
+ uint32_t supercompression = metadata ? avg_read_u32le((const uint8_t *)fileData.bytes + 44) : 0;
772
+ if (metadata && self.stream && supercompression == 0) {
773
+ fileData = [reader readAll:&error];
774
+ if (!fileData) metadata = nil;
775
+ if (metadata && [self checkAndPlayUncompressedStreamingFromData:fileData metadata:metadata]) return;
776
+ }
777
+ dispatch_async(dispatch_get_main_queue(), ^{
778
+ if (reader != self->_reader) return;
779
+ if (!metadata) {
780
+ [self notifyError:error];
781
+ return;
782
+ }
783
+ [self applyMetadataAndLoadTextures:metadata sourcePath:nil fileData:fileData reader:reader];
784
+ });
785
+ });
786
+ }
787
+
788
+ /** The header through the key/value data, where the metadata is. */
789
+ - (NSData *)readHeaderFrom:(AVGSourceReader *)reader error:(NSString **)error
790
+ {
791
+ int64_t size = reader.size;
792
+ if (size < 0) {
793
+ *error = @"The file's size isn't known (the server sent no Content-Length or Content-Range)";
794
+ return nil;
795
+ }
796
+ if (size < 104) {
797
+ *error = [NSString stringWithFormat:@"Not a KTX2 file (%lld bytes)", size];
798
+ return nil;
799
+ }
800
+ int64_t first = MIN(size, kAVGHeaderGuessBytes);
801
+ NSData *header = [reader readAt:0 length:first error:error];
802
+ if (!header) return nil;
803
+ const uint8_t *bytes = (const uint8_t *)header.bytes;
804
+ int64_t kvdEnd = (int64_t)avg_read_u32le(bytes + 56) + avg_read_u32le(bytes + 60);
805
+ if (kvdEnd > first && kvdEnd <= size) header = [reader readAt:0 length:kvdEnd error:error];
806
+ return header;
807
+ }
808
+
809
+ /**
810
+ * The seek table from a level's last bytes, or an empty one when it has none. NO with error set
811
+ * when they can't be read.
812
+ */
813
+ - (BOOL)readSeekTable:(AVGZstdSeekTable *)table
814
+ from:(AVGSourceReader *)reader
815
+ levelOffset:(uint64_t)levelOffset
816
+ levelLength:(uint64_t)levelLength
817
+ decompressedLength:(uint64_t)decompressedLength
818
+ error:(NSString **)error
819
+ {
820
+ memset(table, 0, sizeof(*table));
821
+ uint64_t guess = MIN(levelLength, (uint64_t)AVG_ZSTD_SEEK_TAIL_GUESS);
822
+ NSData *tail = [reader readAt:(int64_t)(levelOffset + levelLength - guess) length:(int64_t)guess error:error];
823
+ if (!tail) return NO;
824
+ uint64_t needed = avg_zstd_seek_table_tail_size((const uint8_t *)tail.bytes, tail.length);
825
+ if (needed > guess && needed <= levelLength) {
826
+ tail = [reader readAt:(int64_t)(levelOffset + levelLength - needed) length:(int64_t)needed error:error];
827
+ if (!tail) return NO;
828
+ }
829
+ avg_zstd_seek_table_parse_tail((const uint8_t *)tail.bytes, tail.length, levelLength, decompressedLength, table);
830
+ return YES;
831
+ }
832
+
833
+ /** Ends streaming on a read or decompression error, with onError. */
834
+ - (void)failStream:(const char *)error
835
+ {
836
+ if (_streamFailed) return;
837
+ _streamFailed = YES;
838
+ NSString *why = @(error);
839
+ if (_reader && strcmp(error, "reading failed") == 0) why = _reader.lastError;
840
+ _frameOwed = NO;
841
+ [self pause];
842
+ [self updateDisplayLink];
843
+ [self notifyError:[NSString stringWithFormat:@"Reading %@ failed: %@", _sourceUri, why]];
844
+ }
845
+
846
+ #pragma mark - On-Demand Frame Decoding
847
+
848
+ - (void)decodeFramesAhead:(NSInteger)currentVirtualPos
849
+ {
850
+ if (!_dstream || (!_zstdStream.data && !_zstdStream.read) || !_streamingRingBufferMode || _streamFailed) {
851
+ static int skipCount = 0;
852
+ if (skipCount++ < 5) {
853
+ AVG_VLOG(@"decodeFramesAhead skipping - dstream=%p, ringMode=%d, failed=%d",
854
+ _dstream, _streamingRingBufferMode, _streamFailed);
855
+ }
856
+ return;
857
+ }
858
+
859
+ // A seek still catching up: nothing past it can be decoded yet
860
+ if (![self skipPendingBytesUntil:CACurrentMediaTime() + kSeekSkipSliceSeconds]) return;
861
+
862
+ // Calculate how many frames to decode ahead
863
+ // Use relative distance to handle wraparound correctly (matching Android approach)
864
+ NSInteger vPosModulus = self.totalFrames * 2; // Bounded virtual position space
865
+ NSInteger currentDecodeVPos = (_decoderLoopIteration * self.totalFrames + _decoderPosition) % vPosModulus;
866
+
867
+ // Calculate frames ahead using relative distance
868
+ NSInteger framesAhead = (currentDecodeVPos - currentVirtualPos + vPosModulus) % vPosModulus;
869
+ // framesAhead counts the shown frame
870
+ NSInteger framesToDecode = self.streamingPrefetchAhead + 1 - framesAhead;
871
+
872
+ if (framesToDecode <= 0) {
873
+ static int skipLogCount = 0;
874
+ if (skipLogCount++ < 10 || skipLogCount % 60 == 0) {
875
+ AVG_VLOG(@"decodeFramesAhead skipping - vPos=%ld, decodeVPos=%ld, framesAhead=%ld (already ahead enough)",
876
+ (long)currentVirtualPos, (long)currentDecodeVPos, (long)framesAhead);
877
+ }
878
+ return; // Already ahead enough
879
+ }
880
+
881
+ static int decodeLogCount = 0;
882
+ if (decodeLogCount++ < 20 || framesToDecode > 0) {
883
+ static int logThrottle = 0;
884
+ if (logThrottle++ % 30 == 0 || framesToDecode > 0) {
885
+ AVG_VLOG(@"decodeFramesAhead vPos=%ld, decodeVPos=%ld, framesAhead=%ld, framesToDecode=%ld, decoderPos=%ld, decoderLoop=%ld",
886
+ (long)currentVirtualPos, (long)currentDecodeVPos, (long)framesAhead, (long)framesToDecode,
887
+ (long)_decoderPosition, (long)_decoderLoopIteration);
888
+ }
889
+ }
890
+
891
+ while (framesToDecode > 0) {
892
+ // Check if we need to restart decompression for looping
893
+ if (_decoderPosition >= self.totalFrames) {
894
+ _decoderPosition = 0;
895
+ _decoderLoopIteration++;
896
+
897
+ avg_zstd_stream_restart(&_zstdStream, 0);
898
+ [_frameAccumulator setLength:0];
899
+
900
+ AVG_VLOG(@"Decoder wrap-around: loop %ld, decoding from the start", (long)_decoderLoopIteration);
901
+
902
+ // After wrap-around, recalculate how many frames we need to decode
903
+ // Use the same formula as at the start of the method
904
+ NSInteger newDecodeVPos = (_decoderLoopIteration * self.totalFrames + _decoderPosition) % vPosModulus;
905
+ NSInteger newFramesAhead = (newDecodeVPos - currentVirtualPos + vPosModulus) % vPosModulus;
906
+ framesToDecode = self.streamingPrefetchAhead + 1 - newFramesAhead;
907
+ if (framesToDecode <= 0) {
908
+ AVG_VLOG(@"After wrap-around: already ahead enough, stopping decode");
909
+ break;
910
+ }
911
+ AVG_VLOG(@"After wrap-around: newDecodeVPos=%ld, framesAhead=%ld, framesToDecode=%ld",
912
+ (long)newDecodeVPos, (long)newFramesAhead, (long)framesToDecode);
913
+ // Don't continue - fall through to decompress the header and first frames
914
+ // The continue was causing us to skip decompression after wrap-around
915
+ }
916
+
917
+ // Decompress the rest of the frame straight into the accumulator, across zstd frame ends
918
+ NSUInteger held = _frameAccumulator.length;
919
+ if (held < _streamBytesPerLayer) {
920
+ const char *error = NULL;
921
+ [_frameAccumulator setLength:_streamBytesPerLayer];
922
+ size_t got = avg_zstd_stream_read(&_zstdStream, (uint8_t *)_frameAccumulator.mutableBytes + held,
923
+ _streamBytesPerLayer - held, &error);
924
+ [_frameAccumulator setLength:held + got];
925
+ if (error) {
926
+ [self failStream:error];
927
+ return;
928
+ }
929
+ // Short of a frame while input is on its way: the next display tick carries on
930
+ if (_frameAccumulator.length < _streamBytesPerLayer && !_zstdStream.inputPending) {
931
+ AVG_VLOG(@"ZSTD stream ended at decoderPos=%ld, accumLen=%lu",
932
+ (long)_decoderPosition, (unsigned long)_frameAccumulator.length);
933
+ }
934
+ }
935
+
936
+ // Process complete frame
937
+ if (_frameAccumulator.length >= _streamBytesPerLayer) {
938
+ NSInteger virtualPos = (_decoderLoopIteration * self.totalFrames + _decoderPosition) % vPosModulus;
939
+ NSInteger gpuSlot = _decoderPosition % _streamingRingBufferSize;
940
+
941
+ // Check if we can upload to this GPU slot
942
+ NSInteger slotCurrentVPos = [_ringSlots[gpuSlot] integerValue];
943
+
944
+ // Calculate if slot is "old" (behind playback) using relative distance
945
+ // A slot is old if it's behind the current playback position
946
+ // We also allow overwriting if the slot is from a different loop iteration
947
+ // (i.e., slot has vPos from loop 0 but we're decoding for loop 1)
948
+ NSInteger slotAge = (currentVirtualPos - slotCurrentVPos + vPosModulus) % vPosModulus;
949
+ BOOL slotIsOld = slotAge > 0 && slotAge < vPosModulus / 2;
950
+
951
+ // Also allow overwriting if the slot's frame is from a previous loop
952
+ // This handles the wrap-around case where decoder is ahead of playback
953
+ NSInteger slotActualFrame = slotCurrentVPos % self.totalFrames;
954
+ NSInteger decoderActualFrame = _decoderPosition;
955
+ BOOL slotFromPreviousLoop = (slotCurrentVPos != -1) &&
956
+ (slotActualFrame == decoderActualFrame) &&
957
+ (slotCurrentVPos != virtualPos);
958
+
959
+ BOOL canOverwrite = (slotCurrentVPos == -1) || (slotCurrentVPos == virtualPos) || slotIsOld || slotFromPreviousLoop;
960
+
961
+ // In RAM mode, ALWAYS cache the frame first (CPU cache is primary storage)
962
+ // In VRAM mode, only cache if we can't upload to GPU
963
+ if (!_useVramStrategy) {
964
+ // RAM mode: Always cache to CPU
965
+ NSData *frameData = [NSData dataWithBytes:_frameAccumulator.bytes length:_streamBytesPerLayer];
966
+ _frameCache[@(virtualPos)] = frameData;
967
+ }
968
+
969
+ if (canOverwrite && slotCurrentVPos != virtualPos) {
970
+ // Upload frame to GPU
971
+ static int uploadLogCount = 0;
972
+ if (uploadLogCount++ < 30) {
973
+ AVG_VLOG(@"Uploading frame %ld to GPU slot %ld (vPos=%ld, loop=%ld)",
974
+ (long)_decoderPosition, (long)gpuSlot, (long)virtualPos, (long)_decoderLoopIteration);
975
+ }
976
+ [self uploadFrameToGPU:_frameAccumulator.bytes slot:gpuSlot];
977
+ _ringSlots[gpuSlot] = @(virtualPos);
978
+ } else if (!canOverwrite && _useVramStrategy) {
979
+ // VRAM mode: Can't upload, cache temporarily
980
+ static int blockLogCount = 0;
981
+ if (blockLogCount++ < 10) {
982
+ NSLog(@"AlphaVideoGpuView: Can't upload frame %ld to slot %ld - caching (slotVPos=%ld, virtualPos=%ld, slotAge=%ld)",
983
+ (long)_decoderPosition, (long)gpuSlot, (long)slotCurrentVPos, (long)virtualPos, (long)slotAge);
984
+ }
985
+ NSData *frameData = [NSData dataWithBytes:_frameAccumulator.bytes length:_streamBytesPerLayer];
986
+ _frameCache[@(virtualPos)] = frameData;
987
+ }
988
+
989
+ _decoderPosition++;
990
+ framesToDecode--;
991
+
992
+ // Remove processed frame data (in-place shift, no allocation)
993
+ if (_frameAccumulator.length > _streamBytesPerLayer) {
994
+ [_frameAccumulator replaceBytesInRange:NSMakeRange(0, _streamBytesPerLayer) withBytes:NULL length:0];
995
+ } else {
996
+ [_frameAccumulator setLength:0];
997
+ }
998
+ } else {
999
+ break; // No more data available
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Decompresses and drops the bytes a seek still has to get past, until the deadline
1006
+ * (CACurrentMediaTime()); YES once none are left.
1007
+ */
1008
+ - (BOOL)skipPendingBytesUntil:(CFTimeInterval)deadline
1009
+ {
1010
+ if (_pendingSkipBytes == 0) return YES;
1011
+ uint64_t held = MIN((uint64_t)_frameAccumulator.length, _pendingSkipBytes);
1012
+ [_frameAccumulator replaceBytesInRange:NSMakeRange(0, (NSUInteger)held) withBytes:NULL length:0];
1013
+ _pendingSkipBytes -= held;
1014
+
1015
+ NSMutableData *scratch = [NSMutableData dataWithLength:ZSTD_DStreamOutSize()];
1016
+ while (_pendingSkipBytes > 0) {
1017
+ const char *error = NULL;
1018
+ size_t want = (size_t)MIN((uint64_t)scratch.length, _pendingSkipBytes);
1019
+ size_t got = avg_zstd_stream_read(&_zstdStream, scratch.mutableBytes, want, &error);
1020
+ if (error) {
1021
+ [self failStream:error];
1022
+ return NO;
1023
+ }
1024
+ if (got == 0) {
1025
+ if (_zstdStream.inputPending) return NO;
1026
+ _pendingSkipBytes = 0; // past the end of the data
1027
+ break;
1028
+ }
1029
+ _pendingSkipBytes -= got;
1030
+ if (_pendingSkipBytes > 0 && CACurrentMediaTime() >= deadline) return NO;
1031
+ }
1032
+ return YES;
1033
+ }
1034
+
1035
+ /**
1036
+ * Points the streaming decoder at target, keeping what is decoded when target is among the
1037
+ * buffered frames (from the shown one up to the decoder).
1038
+ */
1039
+ - (void)moveStreamTo:(NSInteger)target
1040
+ {
1041
+ if (_isUncompressedStreaming) {
1042
+ if (!_useCompressedFormat && _decodedRgbaCache) {
1043
+ // Decode blocks in flight read and advance the position, so reset it between them
1044
+ dispatch_sync(_astcDecodeQueue, ^{
1045
+ @synchronized(self->_decodedRgbaCache) {
1046
+ [self->_decodedRgbaCache removeAllObjects];
1047
+ }
1048
+ self->_uncompressedDecoderPosition = target;
1049
+ self->_decoderLoopIteration = 0;
1050
+ });
1051
+ }
1052
+ } else if (_dstream) {
1053
+ BOOL samePass = _decoderLoopIteration % 2 == _playbackLoopIteration % 2;
1054
+ if (samePass && target >= self.currentFrame && target < _decoderPosition) return;
1055
+ // Carry on decoding when that is no further from the target than the nearest seek point
1056
+ // (the level start, without a seek table). The skip itself happens over the next display
1057
+ // ticks, in decodeFramesAhead.
1058
+ uint64_t bytesPerLayer = _streamBytesPerLayer;
1059
+ uint64_t targetBytes = (uint64_t)target * bytesPerLayer;
1060
+ AVGZstdSeekPoint point = avg_zstd_seek_point_before(&_seekTable, targetBytes);
1061
+ uint64_t decoded = (uint64_t)_decoderPosition * bytesPerLayer + _frameAccumulator.length - _pendingSkipBytes;
1062
+ if (samePass && target >= _decoderPosition && avg_zstd_carry_on(decoded, point)) {
1063
+ _pendingSkipBytes += (uint64_t)(target - _decoderPosition) * bytesPerLayer;
1064
+ } else {
1065
+ avg_zstd_stream_restart(&_zstdStream, point.compressedOffset);
1066
+ [_frameAccumulator setLength:0];
1067
+ _pendingSkipBytes = targetBytes - point.decompressedOffset;
1068
+ }
1069
+ _decoderPosition = target;
1070
+ _decoderLoopIteration = 0;
1071
+ [_frameCache removeAllObjects];
1072
+ }
1073
+ _playbackLoopIteration = 0;
1074
+ for (NSUInteger i = 0; i < _ringSlots.count; i++) {
1075
+ _ringSlots[i] = @(-1);
1076
+ }
1077
+ }
1078
+
1079
+ /** Decodes towards the shown frame; YES once it can be drawn. */
1080
+ - (BOOL)prepareShownFrame
1081
+ {
1082
+ if (!_streamingRingBufferMode) return YES;
1083
+ NSInteger virtualPos = (_playbackLoopIteration * self.totalFrames + self.currentFrame) % (self.totalFrames * 2);
1084
+ NSInteger gpuSlot = self.currentFrame % _streamingRingBufferSize;
1085
+ if ([_ringSlots[gpuSlot] integerValue] == virtualPos) return YES;
1086
+ if (_isUncompressedStreaming) {
1087
+ // A device uploads straight from the mapping as it draws
1088
+ if (_useCompressedFormat || !_decodedRgbaCache) return YES;
1089
+ [self decodeUncompressedFramesAhead:virtualPos];
1090
+ @synchronized(_decodedRgbaCache) {
1091
+ return _decodedRgbaCache[@(virtualPos)] != nil;
1092
+ }
1093
+ }
1094
+ [self decodeFramesAhead:virtualPos];
1095
+ return _frameCache[@(virtualPos)] != nil || [_ringSlots[gpuSlot] integerValue] == virtualPos;
1096
+ }
1097
+
1098
+ - (void)uploadFrameToGPU:(const void *)frameData slot:(NSInteger)slot
1099
+ {
1100
+ if (!_textureArray) return;
1101
+
1102
+ MTLRegion region = MTLRegionMake2D(0, 0, self.frameWidth, self.frameHeight);
1103
+
1104
+ if (_useCompressedFormat) {
1105
+ // Use async blit if staging buffers are available
1106
+ if (_stagingBuffers[0] && _blitCommandQueue) {
1107
+ // Copy to staging buffer
1108
+ id<MTLBuffer> staging = _stagingBuffers[_currentStagingBuffer];
1109
+ memcpy(staging.contents, frameData, _streamBytesPerLayer);
1110
+
1111
+ // Create blit command buffer
1112
+ id<MTLCommandBuffer> cmdBuf = [_blitCommandQueue commandBuffer];
1113
+ id<MTLBlitCommandEncoder> blit = [cmdBuf blitCommandEncoder];
1114
+
1115
+ // Async copy from buffer to texture
1116
+ [blit copyFromBuffer:staging
1117
+ sourceOffset:0
1118
+ sourceBytesPerRow:0
1119
+ sourceBytesPerImage:_streamBytesPerLayer
1120
+ sourceSize:MTLSizeMake(self.frameWidth, self.frameHeight, 1)
1121
+ toTexture:_textureArray
1122
+ destinationSlice:slot
1123
+ destinationLevel:0
1124
+ destinationOrigin:MTLOriginMake(0, 0, 0)];
1125
+
1126
+ [blit endEncoding];
1127
+ [cmdBuf commit]; // Async - returns immediately
1128
+
1129
+ // Swap staging buffer for next upload
1130
+ _currentStagingBuffer = 1 - _currentStagingBuffer;
1131
+ } else {
1132
+ // Fallback to synchronous replaceRegion
1133
+ [_textureArray replaceRegion:region
1134
+ mipmapLevel:0
1135
+ slice:slot
1136
+ withBytes:frameData
1137
+ bytesPerRow:0
1138
+ bytesPerImage:_streamBytesPerLayer];
1139
+ }
1140
+ } else {
1141
+ // Decode ASTC to RGBA for simulator
1142
+ uint32_t bw = _astcBlockWidth;
1143
+ uint32_t bh = _astcBlockHeight;
1144
+ uint32_t blocksX = ((uint32_t)self.frameWidth + bw - 1) / bw;
1145
+ uint32_t blocksY = ((uint32_t)self.frameHeight + bh - 1) / bh;
1146
+ std::vector<uint8_t> rgbaData(self.frameWidth * self.frameHeight * 4);
1147
+
1148
+ const uint8_t *astcData = (const uint8_t *)frameData;
1149
+ for (uint32_t by = 0; by < blocksY; by++) {
1150
+ for (uint32_t bx = 0; bx < blocksX; bx++) {
1151
+ const uint8_t *block = astcData + (by * blocksX + bx) * 16;
1152
+ uint8_t blockPixels[12 * 12 * 4]; // max ASTC block size
1153
+ basisu::astc::decompress(blockPixels, block, false, bw, bh);
1154
+
1155
+ for (uint32_t y = 0; y < bh && (by * bh + y) < self.frameHeight; y++) {
1156
+ for (uint32_t x = 0; x < bw && (bx * bw + x) < self.frameWidth; x++) {
1157
+ uint32_t dstX = bx * bw + x;
1158
+ uint32_t dstY = by * bh + y;
1159
+ uint8_t *dst = rgbaData.data() + (dstY * self.frameWidth + dstX) * 4;
1160
+ uint8_t *src = blockPixels + (y * bw + x) * 4;
1161
+ memcpy(dst, src, 4);
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ [_textureArray replaceRegion:region
1168
+ mipmapLevel:0
1169
+ slice:slot
1170
+ withBytes:rgbaData.data()
1171
+ bytesPerRow:self.frameWidth * 4
1172
+ bytesPerImage:self.frameWidth * self.frameHeight * 4];
1173
+ }
1174
+ }
1175
+
1176
+ - (void)ensureFrameInGPU:(NSInteger)virtualPos
1177
+ {
1178
+ if (!_streamingRingBufferMode) return;
1179
+
1180
+ NSInteger actualFrame = virtualPos % self.totalFrames;
1181
+ NSInteger gpuSlot = actualFrame % _streamingRingBufferSize;
1182
+
1183
+ // Check if already loaded
1184
+ NSInteger slotVPos = [_ringSlots[gpuSlot] integerValue];
1185
+ if (slotVPos == virtualPos) return;
1186
+
1187
+ // Check CPU cache
1188
+ NSData *frameData = _frameCache[@(virtualPos)];
1189
+ if (!frameData) {
1190
+ static int missCount = 0;
1191
+ if (missCount++ < 30) {
1192
+ AVG_VLOG(@"ensureFrameInGPU - cache miss for vPos=%ld, slot=%ld has vPos=%ld, cacheSize=%lu",
1193
+ (long)virtualPos, (long)gpuSlot, (long)slotVPos, (unsigned long)_frameCache.count);
1194
+ }
1195
+ return;
1196
+ }
1197
+
1198
+ // Upload to GPU
1199
+ static int ensureUploadCount = 0;
1200
+ if (ensureUploadCount++ < 30) {
1201
+ AVG_VLOG(@"ensureFrameInGPU - uploading vPos=%ld to slot=%ld from cache",
1202
+ (long)virtualPos, (long)gpuSlot);
1203
+ }
1204
+ [self uploadFrameToGPU:frameData.bytes slot:gpuSlot];
1205
+ _ringSlots[gpuSlot] = @(virtualPos);
1206
+
1207
+ // In VRAM mode, remove from CPU cache after upload
1208
+ if (_useVramStrategy) {
1209
+ [_frameCache removeObjectForKey:@(virtualPos)];
1210
+ }
1211
+ }
1212
+
1213
+ - (void)evictOldFrames:(NSInteger)currentVirtualPos
1214
+ {
1215
+ if (!_frameCache) return;
1216
+
1217
+ NSInteger vPosModulus = self.totalFrames * 2;
1218
+ NSMutableArray *toRemove = [NSMutableArray array];
1219
+
1220
+ if (_useVramStrategy) {
1221
+ // VRAM mode: Only evict frames that have been played
1222
+ for (NSNumber *virtualPos in _frameCache.allKeys) {
1223
+ NSInteger vPos = [virtualPos integerValue];
1224
+ NSInteger age = (currentVirtualPos - vPos + vPosModulus) % vPosModulus;
1225
+ // Frame is "old" if it's behind playback (age > 0) but not wrapped around (age < half modulus)
1226
+ if (age > 0 && age < vPosModulus / 2) {
1227
+ [toRemove addObject:virtualPos];
1228
+ }
1229
+ }
1230
+ } else {
1231
+ // RAM mode: Use sliding window with relative distance
1232
+ // Keep the previous frame and the ones decoded ahead
1233
+ for (NSNumber *virtualPos in _frameCache.allKeys) {
1234
+ NSInteger vPos = [virtualPos integerValue];
1235
+ NSInteger distance = (vPos - currentVirtualPos + vPosModulus) % vPosModulus;
1236
+ // distance > vPosModulus - 2 means frame is 1 behind (wrapped)
1237
+ BOOL isBehind = distance > (NSInteger)(vPosModulus - 2);
1238
+ BOOL isAhead = distance <= self.streamingPrefetchAhead;
1239
+ if (!isBehind && !isAhead) {
1240
+ [toRemove addObject:virtualPos];
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ for (NSNumber *virtualPos in toRemove) {
1246
+ [_frameCache removeObjectForKey:virtualPos];
1247
+ }
1248
+ }
1249
+
1250
+ #pragma mark - Uncompressed Streaming (mmap)
1251
+
1252
+ // With the stream prop, plays an uncompressed KTX2 texture array by mmap streaming, reading only
1253
+ // the header and metadata so the file is never loaded into memory.
1254
+ - (BOOL)checkAndPlayUncompressedStreaming:(NSString *)filePath
1255
+ {
1256
+ if (!self.stream) return NO;
1257
+ AVG_VLOG(@"checkAndPlayUncompressedStreaming: %@", filePath);
1258
+
1259
+ // Open file
1260
+ int fd = open([filePath UTF8String], O_RDONLY);
1261
+ if (fd < 0) {
1262
+ NSLog(@"AlphaVideoGpuView: Failed to open file for mmap");
1263
+ return NO;
1264
+ }
1265
+
1266
+ // Get file size
1267
+ struct stat st;
1268
+ if (fstat(fd, &st) < 0) {
1269
+ close(fd);
1270
+ return NO;
1271
+ }
1272
+ size_t fileSize = st.st_size;
1273
+
1274
+ // Read header (first 256 bytes)
1275
+ uint8_t headerBytes[256];
1276
+ if (read(fd, headerBytes, 256) < 100) {
1277
+ close(fd);
1278
+ return NO;
1279
+ }
1280
+
1281
+ // Check KTX2 magic
1282
+ uint8_t ktx2Magic[] = {0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A};
1283
+ if (memcmp(headerBytes, ktx2Magic, 12) != 0) {
1284
+ close(fd);
1285
+ return NO;
1286
+ }
1287
+
1288
+ // Check supercompressionScheme at offset 44
1289
+ uint32_t supercompression = *(uint32_t *)(headerBytes + 44);
1290
+ if (supercompression != 0) {
1291
+ close(fd);
1292
+ return NO; // Not uncompressed
1293
+ }
1294
+
1295
+ // Read the key/value data for KTXanimData
1296
+ uint32_t kvdOffset = *(uint32_t *)(headerBytes + 56);
1297
+ uint32_t kvdLength = *(uint32_t *)(headerBytes + 60);
1298
+ NSMutableData *kvdBytes = [NSMutableData dataWithLength:MIN(kvdLength, 4096u)];
1299
+ if (kvdBytes.length > 0) {
1300
+ ssize_t kvdRead = pread(fd, kvdBytes.mutableBytes, kvdBytes.length, kvdOffset);
1301
+ kvdBytes.length = kvdRead > 0 ? (NSUInteger)kvdRead : 0;
1302
+ }
1303
+ NSDictionary *metadata = AVGKTX2Metadata(headerBytes, (const uint8_t *)kvdBytes.bytes, kvdBytes.length);
1304
+ // Only ASTC texture arrays stream
1305
+ if (!AVGIsASTCArray(metadata)) {
1306
+ close(fd);
1307
+ return NO;
1308
+ }
1309
+
1310
+ AVG_VLOG(@"Detected uncompressed streaming KTX2 - using mmap");
1311
+
1312
+ // Memory map the file
1313
+ void *mmapData = mmap(NULL, fileSize, PROT_READ, MAP_PRIVATE, fd, 0);
1314
+ if (mmapData == MAP_FAILED) {
1315
+ NSLog(@"AlphaVideoGpuView: mmap failed");
1316
+ close(fd);
1317
+ return NO;
1318
+ }
1319
+
1320
+ // Store mmap state
1321
+ _mmapFd = fd;
1322
+ _mmapData = mmapData;
1323
+ _mmapSize = fileSize;
1324
+ return [self startUncompressedStreamingWithMetadata:metadata];
1325
+ }
1326
+
1327
+ /** checkAndPlayUncompressedStreaming: for a whole file read from a reader, held in memory. */
1328
+ - (BOOL)checkAndPlayUncompressedStreamingFromData:(NSData *)fileData metadata:(NSDictionary *)metadata
1329
+ {
1330
+ if (!self.stream || fileData.length < 104) return NO;
1331
+ if (avg_read_u32le((const uint8_t *)fileData.bytes + 44) != 0) return NO;
1332
+ if (!AVGIsASTCArray(metadata)) return NO;
1333
+ _heldFileData = fileData;
1334
+ _mmapData = (void *)fileData.bytes;
1335
+ _mmapSize = fileData.length;
1336
+ _mmapFd = -1;
1337
+ return [self startUncompressedStreamingWithMetadata:metadata];
1338
+ }
1339
+
1340
+ /** Plays the uncompressed texture array at _mmapData: a mapping, or a file held in memory. */
1341
+ - (BOOL)startUncompressedStreamingWithMetadata:(NSDictionary *)metadata
1342
+ {
1343
+ _isUncompressedStreaming = YES;
1344
+
1345
+ // Parse KTX2 header from mmap
1346
+ const uint8_t *data = (const uint8_t *)_mmapData;
1347
+ uint32_t vkFormat = *(uint32_t *)(data + 12);
1348
+ uint32_t width = *(uint32_t *)(data + 20);
1349
+ uint32_t height = *(uint32_t *)(data + 24);
1350
+ uint32_t layerCount = *(uint32_t *)(data + 32);
1351
+ if (layerCount == 0) layerCount = 1;
1352
+ uint64_t level0Offset = *(uint64_t *)(data + 80);
1353
+
1354
+ // Configure ASTC format from vkFormat
1355
+ [self configureASTCFormatFromVkFormat:vkFormat];
1356
+ uint32_t bytesPerLayer = [self astcBytesPerLayerForWidth:width height:height];
1357
+
1358
+ AVG_VLOG(@"Uncompressed streaming: %ux%u, %u layers, %u bytes/layer (ASTC %ux%u), offset=%llu",
1359
+ width, height, layerCount, bytesPerLayer, _astcBlockWidth, _astcBlockHeight, level0Offset);
1360
+
1361
+ // Frames are read from the mapping as they're shown, nothing ahead (but on the simulator,
1362
+ // which decodes them to RGBA in memory), so a small ring does for either memory strategy
1363
+ BOOL useVramStrategy = ![self.memoryStrategy isEqualToString:@"ram"];
1364
+ NSInteger gpuCacheSize = MIN(kRamRingSize, (NSInteger)layerCount);
1365
+
1366
+ // Setup on main thread
1367
+ dispatch_sync(dispatch_get_main_queue(), ^{
1368
+ self.totalFrames = layerCount;
1369
+ self.frameWidth = width;
1370
+ self.frameHeight = height;
1371
+ self.frameRate = [metadata[@"frameRate"] doubleValue];
1372
+ self.frameInterval = 1.0 / self.frameRate;
1373
+
1374
+ self->_textureArrayLayerCount = layerCount;
1375
+ self->_streamingRingBufferMode = YES;
1376
+ self->_streamingRingBufferSize = gpuCacheSize;
1377
+ self->_useVramStrategy = useVramStrategy;
1378
+ self->_streamBytesPerLayer = bytesPerLayer;
1379
+ self->_streamLevel0Offset = level0Offset;
1380
+
1381
+ // Initialize ring buffer tracking
1382
+ self->_ringSlots = [NSMutableArray arrayWithCapacity:gpuCacheSize];
1383
+ for (NSInteger i = 0; i < gpuCacheSize; i++) {
1384
+ [self->_ringSlots addObject:@(-1)];
1385
+ }
1386
+ self->_playbackLoopIteration = 0;
1387
+
1388
+ // Initialize decoded RGBA cache for simulator ASTC decompression
1389
+ if (!self->_useCompressedFormat) {
1390
+ self->_decodedRgbaCache = [NSMutableDictionary dictionary];
1391
+ self->_uncompressedDecoderPosition = 0;
1392
+ self->_decoderLoopIteration = 0;
1393
+ }
1394
+
1395
+ // Create texture array with ring buffer size
1396
+ MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:self->_metalPixelFormat
1397
+ width:width
1398
+ height:height
1399
+ mipmapped:NO];
1400
+ descriptor.textureType = MTLTextureType2DArray;
1401
+ descriptor.arrayLength = gpuCacheSize;
1402
+ descriptor.usage = MTLTextureUsageShaderRead;
1403
+
1404
+ self->_textureArray = [self.device newTextureWithDescriptor:descriptor];
1405
+
1406
+ if (!self->_textureArray) {
1407
+ [self notifyError:@"Failed to create texture array for uncompressed streaming"];
1408
+ return;
1409
+ }
1410
+
1411
+ AVG_VLOG(@"Created uncompressed streaming texture array: %ux%u, %ld GPU slots",
1412
+ width, height, (long)gpuCacheSize);
1413
+
1414
+ self->_isSourceLoaded = YES;
1415
+ self->_isSourceLoading = NO;
1416
+ [self notifyLoad];
1417
+ [self notifyStateChange:@"ready"];
1418
+
1419
+ // Pre-decode initial frames on background thread to avoid freeze
1420
+ if (!self->_useCompressedFormat) {
1421
+ // Simulator: decode ASTC to RGBA on background, then upload to GPU
1422
+ dispatch_async(self->_astcDecodeQueue, ^{
1423
+ NSInteger framesToPreload = MIN(gpuCacheSize, (NSInteger)layerCount);
1424
+ AVG_VLOG(@"Pre-decoding %ld initial frames for uncompressed streaming (simulator)", (long)framesToPreload);
1425
+
1426
+ for (NSInteger i = 0; i < framesToPreload && self->_mmapData; i++) {
1427
+ // Decode ASTC to RGBA
1428
+ const uint8_t *data = (const uint8_t *)self->_mmapData;
1429
+ uint64_t frameOffset = self->_streamLevel0Offset + (uint64_t)i * self->_streamBytesPerLayer;
1430
+ const uint8_t *frameData = data + frameOffset;
1431
+
1432
+ uint32_t bw = self->_astcBlockWidth;
1433
+ uint32_t bh = self->_astcBlockHeight;
1434
+ uint32_t blocksX = (width + bw - 1) / bw;
1435
+ uint32_t blocksY = (height + bh - 1) / bh;
1436
+ NSMutableData *rgbaData = [NSMutableData dataWithLength:width * height * 4];
1437
+ uint8_t *rgba = (uint8_t *)rgbaData.mutableBytes;
1438
+
1439
+ for (uint32_t by = 0; by < blocksY; by++) {
1440
+ for (uint32_t bx = 0; bx < blocksX; bx++) {
1441
+ const uint8_t *block = frameData + (by * blocksX + bx) * 16;
1442
+ uint8_t blockPixels[12 * 12 * 4]; // max ASTC block size
1443
+ basisu::astc::decompress(blockPixels, block, false, bw, bh);
1444
+
1445
+ for (uint32_t y = 0; y < bh && (by * bh + y) < height; y++) {
1446
+ for (uint32_t x = 0; x < bw && (bx * bw + x) < width; x++) {
1447
+ uint32_t dstX = bx * bw + x;
1448
+ uint32_t dstY = by * bh + y;
1449
+ uint8_t *dst = rgba + (dstY * width + dstX) * 4;
1450
+ uint8_t *src = blockPixels + (y * bw + x) * 4;
1451
+ memcpy(dst, src, 4);
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ // Cache decoded frame
1458
+ @synchronized(self->_decodedRgbaCache) {
1459
+ self->_decodedRgbaCache[@(i)] = rgbaData;
1460
+ }
1461
+ self->_uncompressedDecoderPosition = i + 1;
1462
+ }
1463
+ if (self->_uncompressedDecoderPosition >= self.totalFrames) {
1464
+ self->_uncompressedDecoderPosition = 0;
1465
+ self->_decoderLoopIteration++;
1466
+ }
1467
+
1468
+ // Upload to GPU on main thread (fast - just memcpy)
1469
+ dispatch_async(dispatch_get_main_queue(), ^{
1470
+ for (NSInteger i = 0; i < framesToPreload; i++) {
1471
+ NSInteger slot = i % gpuCacheSize;
1472
+ // A seek since the decode has emptied the cache
1473
+ if ([self uploadDecodedFrameToGPU:i slot:slot]) {
1474
+ self->_ringSlots[slot] = @(i);
1475
+ }
1476
+ }
1477
+
1478
+ AVG_VLOG(@"Pre-decode complete, starting playback");
1479
+
1480
+ if (self.autoPlay && !self.playbackPaused) {
1481
+ [self play];
1482
+ }
1483
+ });
1484
+ });
1485
+ } else {
1486
+ // Real device: direct upload (fast, no ASTC decode needed)
1487
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
1488
+ NSInteger framesToPreload = MIN(gpuCacheSize, (NSInteger)layerCount);
1489
+ AVG_VLOG(@"Pre-uploading %ld initial frames for uncompressed streaming (device)", (long)framesToPreload);
1490
+
1491
+ for (NSInteger i = 0; i < framesToPreload; i++) {
1492
+ NSInteger slot = i % gpuCacheSize;
1493
+ [self uploadUncompressedFrame:i toSlot:slot];
1494
+ self->_ringSlots[slot] = @(i);
1495
+ }
1496
+
1497
+ AVG_VLOG(@"Pre-upload complete, starting playback");
1498
+
1499
+ dispatch_async(dispatch_get_main_queue(), ^{
1500
+ if (self.autoPlay && !self.playbackPaused) {
1501
+ [self play];
1502
+ }
1503
+ });
1504
+ });
1505
+ }
1506
+ });
1507
+
1508
+ return YES;
1509
+ }
1510
+
1511
+ - (void)uploadUncompressedFrame:(NSInteger)frameIndex toSlot:(NSInteger)slot
1512
+ {
1513
+ if (!_mmapData || !_textureArray) return;
1514
+
1515
+ const uint8_t *data = (const uint8_t *)_mmapData;
1516
+ uint64_t frameOffset = _streamLevel0Offset + (uint64_t)frameIndex * _streamBytesPerLayer;
1517
+ const uint8_t *frameData = data + frameOffset;
1518
+
1519
+ MTLRegion region = MTLRegionMake2D(0, 0, self.frameWidth, self.frameHeight);
1520
+
1521
+ if (_useCompressedFormat) {
1522
+ [_textureArray replaceRegion:region
1523
+ mipmapLevel:0
1524
+ slice:slot
1525
+ withBytes:frameData
1526
+ bytesPerRow:0
1527
+ bytesPerImage:_streamBytesPerLayer];
1528
+ } else {
1529
+ // Decode ASTC to RGBA for simulator
1530
+ uint32_t bw = _astcBlockWidth;
1531
+ uint32_t bh = _astcBlockHeight;
1532
+ uint32_t blocksX = ((uint32_t)self.frameWidth + bw - 1) / bw;
1533
+ uint32_t blocksY = ((uint32_t)self.frameHeight + bh - 1) / bh;
1534
+ std::vector<uint8_t> rgbaData(self.frameWidth * self.frameHeight * 4);
1535
+
1536
+ for (uint32_t by = 0; by < blocksY; by++) {
1537
+ for (uint32_t bx = 0; bx < blocksX; bx++) {
1538
+ const uint8_t *block = frameData + (by * blocksX + bx) * 16;
1539
+ uint8_t blockPixels[12 * 12 * 4]; // max ASTC block size
1540
+ basisu::astc::decompress(blockPixels, block, false, bw, bh);
1541
+
1542
+ for (uint32_t y = 0; y < bh && (by * bh + y) < self.frameHeight; y++) {
1543
+ for (uint32_t x = 0; x < bw && (bx * bw + x) < self.frameWidth; x++) {
1544
+ uint32_t dstX = bx * bw + x;
1545
+ uint32_t dstY = by * bh + y;
1546
+ uint8_t *dst = rgbaData.data() + (dstY * self.frameWidth + dstX) * 4;
1547
+ uint8_t *src = blockPixels + (y * bw + x) * 4;
1548
+ memcpy(dst, src, 4);
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+
1554
+ [_textureArray replaceRegion:region
1555
+ mipmapLevel:0
1556
+ slice:slot
1557
+ withBytes:rgbaData.data()
1558
+ bytesPerRow:self.frameWidth * 4
1559
+ bytesPerImage:self.frameWidth * self.frameHeight * 4];
1560
+ }
1561
+ }
1562
+
1563
+ // Decode ASTC frames ahead on background thread for uncompressed streaming (simulator only)
1564
+ - (void)decodeUncompressedFramesAhead:(NSInteger)currentVirtualPos
1565
+ {
1566
+ if (!_mmapData || !_isUncompressedStreaming || _useCompressedFormat) return;
1567
+ if (_uncompressedDecodeQueued) return;
1568
+ _uncompressedDecodeQueued = YES;
1569
+
1570
+ // Only needed on simulator where we decode ASTC to RGBA
1571
+ NSInteger vPosModulus = self.totalFrames * 2;
1572
+ // framesAhead counts the shown frame
1573
+ NSInteger aheadLimit = self.streamingPrefetchAhead + 1;
1574
+
1575
+ // The position belongs to the decode queue, so the batch is sized there
1576
+ dispatch_async(_astcDecodeQueue, ^{
1577
+ NSInteger currentDecodeVPos = (self->_decoderLoopIteration * self.totalFrames + self->_uncompressedDecoderPosition) % vPosModulus;
1578
+ NSInteger framesAhead = (currentDecodeVPos - currentVirtualPos + vPosModulus) % vPosModulus;
1579
+ NSInteger framesToDecode = aheadLimit - framesAhead;
1580
+ for (NSInteger i = 0; i < framesToDecode && self->_mmapData; i++) {
1581
+ NSInteger frameIndex = self->_uncompressedDecoderPosition;
1582
+ NSInteger virtualPos = (self->_decoderLoopIteration * self.totalFrames + frameIndex) % vPosModulus;
1583
+
1584
+ // Skip if already decoded
1585
+ if (self->_decodedRgbaCache[@(virtualPos)]) {
1586
+ self->_uncompressedDecoderPosition++;
1587
+ if (self->_uncompressedDecoderPosition >= self.totalFrames) {
1588
+ self->_uncompressedDecoderPosition = 0;
1589
+ self->_decoderLoopIteration++;
1590
+ }
1591
+ continue;
1592
+ }
1593
+
1594
+ // Decode ASTC to RGBA
1595
+ const uint8_t *data = (const uint8_t *)self->_mmapData;
1596
+ uint64_t frameOffset = self->_streamLevel0Offset + (uint64_t)frameIndex * self->_streamBytesPerLayer;
1597
+ const uint8_t *frameData = data + frameOffset;
1598
+
1599
+ uint32_t bw = self->_astcBlockWidth;
1600
+ uint32_t bh = self->_astcBlockHeight;
1601
+ uint32_t blocksX = ((uint32_t)self.frameWidth + bw - 1) / bw;
1602
+ uint32_t blocksY = ((uint32_t)self.frameHeight + bh - 1) / bh;
1603
+ NSMutableData *rgbaData = [NSMutableData dataWithLength:self.frameWidth * self.frameHeight * 4];
1604
+ uint8_t *rgba = (uint8_t *)rgbaData.mutableBytes;
1605
+
1606
+ for (uint32_t by = 0; by < blocksY; by++) {
1607
+ for (uint32_t bx = 0; bx < blocksX; bx++) {
1608
+ const uint8_t *block = frameData + (by * blocksX + bx) * 16;
1609
+ uint8_t blockPixels[12 * 12 * 4]; // max ASTC block size
1610
+ basisu::astc::decompress(blockPixels, block, false, bw, bh);
1611
+
1612
+ for (uint32_t y = 0; y < bh && (by * bh + y) < self.frameHeight; y++) {
1613
+ for (uint32_t x = 0; x < bw && (bx * bw + x) < self.frameWidth; x++) {
1614
+ uint32_t dstX = bx * bw + x;
1615
+ uint32_t dstY = by * bh + y;
1616
+ uint8_t *dst = rgba + (dstY * self.frameWidth + dstX) * 4;
1617
+ uint8_t *src = blockPixels + (y * bw + x) * 4;
1618
+ memcpy(dst, src, 4);
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+
1624
+ // Cache the decoded frame
1625
+ @synchronized(self->_decodedRgbaCache) {
1626
+ self->_decodedRgbaCache[@(virtualPos)] = rgbaData;
1627
+ }
1628
+
1629
+ self->_uncompressedDecoderPosition++;
1630
+ if (self->_uncompressedDecoderPosition >= self.totalFrames) {
1631
+ self->_uncompressedDecoderPosition = 0;
1632
+ self->_decoderLoopIteration++;
1633
+ }
1634
+ }
1635
+ dispatch_async(dispatch_get_main_queue(), ^{
1636
+ self->_uncompressedDecodeQueued = NO;
1637
+ });
1638
+ });
1639
+ }
1640
+
1641
+ // Upload pre-decoded RGBA frame to GPU (fast, main thread safe); NO when it isn't decoded
1642
+ - (BOOL)uploadDecodedFrameToGPU:(NSInteger)virtualPos slot:(NSInteger)slot
1643
+ {
1644
+ NSData *rgbaData;
1645
+ @synchronized(_decodedRgbaCache) {
1646
+ rgbaData = _decodedRgbaCache[@(virtualPos)];
1647
+ }
1648
+
1649
+ if (!rgbaData || !_textureArray) return NO;
1650
+
1651
+ MTLRegion region = MTLRegionMake2D(0, 0, self.frameWidth, self.frameHeight);
1652
+ [_textureArray replaceRegion:region
1653
+ mipmapLevel:0
1654
+ slice:slot
1655
+ withBytes:rgbaData.bytes
1656
+ bytesPerRow:self.frameWidth * 4
1657
+ bytesPerImage:self.frameWidth * self.frameHeight * 4];
1658
+ return YES;
1659
+ }
1660
+
1661
+ // Evict old decoded frames from cache
1662
+ - (void)evictOldDecodedFrames:(NSInteger)currentVirtualPos
1663
+ {
1664
+ if (!_decodedRgbaCache) return;
1665
+
1666
+ NSInteger vPosModulus = self.totalFrames * 2;
1667
+ NSMutableArray *toRemove = [NSMutableArray array];
1668
+
1669
+ @synchronized(_decodedRgbaCache) {
1670
+ for (NSNumber *virtualPos in _decodedRgbaCache.allKeys) {
1671
+ NSInteger vPos = [virtualPos integerValue];
1672
+ NSInteger distance = (vPos - currentVirtualPos + vPosModulus) % vPosModulus;
1673
+ BOOL isBehind = distance > (NSInteger)(vPosModulus - 2);
1674
+ BOOL isAhead = distance <= self.streamingPrefetchAhead;
1675
+ if (!isBehind && !isAhead) {
1676
+ [toRemove addObject:virtualPos];
1677
+ }
1678
+ }
1679
+
1680
+ for (NSNumber *virtualPos in toRemove) {
1681
+ [_decodedRgbaCache removeObjectForKey:virtualPos];
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * With a reader, fileData holds the file from its start, through at least the header; otherwise
1688
+ * it is nil and the file is at sourcePath.
1689
+ */
1690
+ - (void)applyMetadataAndLoadTextures:(NSDictionary *)metadata
1691
+ sourcePath:(NSString *)sourcePath
1692
+ fileData:(NSData *)fileData
1693
+ reader:(AVGSourceReader *)reader
1694
+ {
1695
+ if (!AVGIsASTCArray(metadata)) {
1696
+ [self notifyError:[NSString stringWithFormat:@"Only ASTC texture arrays play (this file has vkFormat %@ and %@ layers); encode it with texenc",
1697
+ metadata[@"vkFormat"], metadata[@"frameCount"]]];
1698
+ return;
1699
+ }
1700
+
1701
+ self.totalFrames = [metadata[@"frameCount"] integerValue];
1702
+ self.frameWidth = [metadata[@"frameWidth"] integerValue];
1703
+ self.frameHeight = [metadata[@"frameHeight"] integerValue];
1704
+ self.frameRate = [metadata[@"frameRate"] doubleValue];
1705
+ self.frameInterval = 1.0 / self.frameRate;
1706
+ _textureArrayLayerCount = self.totalFrames;
1707
+
1708
+ AVG_VLOG(@"Metadata: %ld frames, %ldx%ld, %.3f fps",
1709
+ (long)self.totalFrames, (long)self.frameWidth, (long)self.frameHeight, self.frameRate);
1710
+
1711
+ [self loadTextureArray:metadata sourcePath:sourcePath fileData:fileData reader:reader];
1712
+ }
1713
+
1714
+ /**
1715
+ * Decode ASTC compressed data to RGBA using proper decoder.
1716
+ * Uses basisu::astc::decompress from astc_decomp.cpp
1717
+ * Supports all ASTC block sizes via _astcBlockWidth/_astcBlockHeight.
1718
+ */
1719
+ - (NSData *)decodeASTCToRGBA:(NSData *)astcData width:(uint32_t)width height:(uint32_t)height
1720
+ {
1721
+ const uint8_t *blocks = (const uint8_t *)[astcData bytes];
1722
+ const uint32_t bw = _astcBlockWidth;
1723
+ const uint32_t bh = _astcBlockHeight;
1724
+ uint32_t blocksX = (width + bw - 1) / bw;
1725
+ uint32_t blocksY = (height + bh - 1) / bh;
1726
+
1727
+ NSMutableData *rgbaData = [NSMutableData dataWithLength:width * height * 4];
1728
+ uint8_t *rgba = (uint8_t *)[rgbaData mutableBytes];
1729
+ memset(rgba, 0, width * height * 4);
1730
+
1731
+ // Temporary buffer for one decoded block (max 12x12)
1732
+ uint8_t blockPixels[12 * 12 * 4];
1733
+
1734
+ for (uint32_t by = 0; by < blocksY; by++) {
1735
+ for (uint32_t bx = 0; bx < blocksX; bx++) {
1736
+ const uint8_t *block = blocks + (by * blocksX + bx) * 16;
1737
+
1738
+ // Use proper ASTC decoder (sRGB = false for linear decoding)
1739
+ bool success = basisu::astc::decompress(blockPixels, block, false, bw, bh);
1740
+ if (!success) {
1741
+ // Fill with magenta to indicate decode error
1742
+ uint32_t pixelsInBlock = bw * bh;
1743
+ for (uint32_t i = 0; i < pixelsInBlock; i++) {
1744
+ blockPixels[i * 4 + 0] = 255;
1745
+ blockPixels[i * 4 + 1] = 0;
1746
+ blockPixels[i * 4 + 2] = 255;
1747
+ blockPixels[i * 4 + 3] = 255;
1748
+ }
1749
+ }
1750
+
1751
+ // Copy block to output
1752
+ for (uint32_t y = 0; y < bh && (by * bh + y) < height; y++) {
1753
+ for (uint32_t x = 0; x < bw && (bx * bw + x) < width; x++) {
1754
+ uint32_t dstX = bx * bw + x;
1755
+ uint32_t dstY = by * bh + y;
1756
+ uint8_t *dst = rgba + (dstY * width + dstX) * 4;
1757
+ uint8_t *src = blockPixels + (y * bw + x) * 4;
1758
+ dst[0] = src[0];
1759
+ dst[1] = src[1];
1760
+ dst[2] = src[2];
1761
+ dst[3] = src[3];
1762
+ }
1763
+ }
1764
+ }
1765
+ }
1766
+
1767
+ AVG_VLOG(@"Decoded ASTC %ux%u (%u x %u blocks, block=%ux%u) to RGBA", width, height, blocksX, blocksY, bw, bh);
1768
+ return rgbaData;
1769
+ }
1770
+
1771
+ #pragma mark - Texture Array Loading
1772
+
1773
+ /**
1774
+ * Load a KTX2 texture array (one frame per layer).
1775
+ * Uses Metal's MTLTextureType2DArray for efficient frame switching.
1776
+ */
1777
+ - (void)loadTextureArray:(NSDictionary *)metadata
1778
+ sourcePath:(NSString *)filePath
1779
+ fileData:(NSData *)fileData
1780
+ reader:(AVGSourceReader *)reader
1781
+ {
1782
+ // Check for streaming mode from stream prop (not metadata)
1783
+ BOOL streamMode = self.stream;
1784
+
1785
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
1786
+ // Load the KTX2 file
1787
+ NSData *ktx2Data = fileData ?: [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:nil];
1788
+
1789
+ if (!ktx2Data || ktx2Data.length < 104) {
1790
+ dispatch_async(dispatch_get_main_queue(), ^{
1791
+ [self notifyError:@"Failed to load texture array KTX2 file"];
1792
+ });
1793
+ return;
1794
+ }
1795
+
1796
+ // Parse KTX2 header
1797
+ const uint8_t *bytes = (const uint8_t *)[ktx2Data bytes];
1798
+ uint32_t vkFormat = *(uint32_t *)(bytes + 12);
1799
+ uint32_t width = *(uint32_t *)(bytes + 20);
1800
+ uint32_t height = *(uint32_t *)(bytes + 24);
1801
+ uint32_t layerCount = *(uint32_t *)(bytes + 32);
1802
+ uint32_t supercompressionScheme = *(uint32_t *)(bytes + 44);
1803
+
1804
+ // Level 0 index (at byte 80)
1805
+ uint64_t level0Offset = *(uint64_t *)(bytes + 80);
1806
+ uint64_t level0Length = *(uint64_t *)(bytes + 88);
1807
+ uint64_t level0UncompressedLength = *(uint64_t *)(bytes + 96);
1808
+
1809
+ // Configure ASTC format from vkFormat
1810
+ dispatch_sync(dispatch_get_main_queue(), ^{
1811
+ [self configureASTCFormatFromVkFormat:vkFormat];
1812
+ });
1813
+
1814
+ AVG_VLOG(@"Texture array: %ux%u, %u layers, supercompression=%u, streamMode=%d, vkFormat=%u",
1815
+ width, height, layerCount, supercompressionScheme, streamMode, vkFormat);
1816
+
1817
+ // Check for internal zstd streaming mode
1818
+ if (streamMode && supercompressionScheme == 2 && reader) {
1819
+ AVG_VLOG(@"Using internal zstd streaming mode, from a reader");
1820
+ [self loadInternalZstdStreamingFromReader:reader
1821
+ width:width
1822
+ height:height
1823
+ layerCount:layerCount
1824
+ level0Offset:level0Offset
1825
+ level0Length:level0Length
1826
+ metadata:metadata];
1827
+ return;
1828
+ }
1829
+ if (streamMode && supercompressionScheme == 2 && filePath) {
1830
+ AVG_VLOG(@"Using internal zstd streaming mode");
1831
+ [self loadInternalZstdStreamingTextureArray:filePath
1832
+ width:width
1833
+ height:height
1834
+ layerCount:layerCount
1835
+ level0Offset:level0Offset
1836
+ level0Length:level0Length
1837
+ metadata:metadata];
1838
+ return;
1839
+ }
1840
+
1841
+ // Not streamed: a reader's file is read whole, past the header
1842
+ if (reader && (int64_t)ktx2Data.length < reader.size) {
1843
+ NSString *error = nil;
1844
+ ktx2Data = [reader readAll:&error];
1845
+ if (!ktx2Data) {
1846
+ dispatch_async(dispatch_get_main_queue(), ^{
1847
+ if (reader == self->_reader) [self notifyError:error];
1848
+ });
1849
+ return;
1850
+ }
1851
+ bytes = (const uint8_t *)[ktx2Data bytes];
1852
+ }
1853
+ if (level0Offset + level0Length > ktx2Data.length) {
1854
+ dispatch_async(dispatch_get_main_queue(), ^{
1855
+ [self notifyError:@"The KTX2 file is shorter than its level index says"];
1856
+ });
1857
+ return;
1858
+ }
1859
+
1860
+ // Extract ASTC data (non-streaming path)
1861
+ NSData *astcData = nil;
1862
+
1863
+ if (supercompressionScheme == 2) {
1864
+ // Zstd compressed - decompress using zstd (all at once)
1865
+ const void *compressedData = bytes + level0Offset;
1866
+ size_t compressedSize = (size_t)level0Length;
1867
+ size_t decompressedSize = (size_t)level0UncompressedLength;
1868
+
1869
+ NSMutableData *decompressedData = [NSMutableData dataWithLength:decompressedSize];
1870
+ size_t result = ZSTD_decompress([decompressedData mutableBytes], decompressedSize,
1871
+ compressedData, compressedSize);
1872
+
1873
+ if (ZSTD_isError(result)) {
1874
+ NSLog(@"AlphaVideoGpuView: Zstd decompression failed: %s", ZSTD_getErrorName(result));
1875
+ dispatch_async(dispatch_get_main_queue(), ^{
1876
+ [self notifyError:@"Failed to decompress texture array"];
1877
+ });
1878
+ return;
1879
+ }
1880
+
1881
+ astcData = decompressedData;
1882
+ AVG_VLOG(@"Decompressed %llu -> %zu bytes", level0Length, result);
1883
+ } else {
1884
+ // Uncompressed
1885
+ astcData = [NSData dataWithBytes:bytes + level0Offset length:(NSUInteger)level0Length];
1886
+ }
1887
+
1888
+ // Calculate bytes per layer using current ASTC block dimensions
1889
+ uint32_t blocksX = (width + self->_astcBlockWidth - 1) / self->_astcBlockWidth;
1890
+ uint32_t blocksY = (height + self->_astcBlockHeight - 1) / self->_astcBlockHeight;
1891
+ uint32_t bytesPerLayer = blocksX * blocksY * 16;
1892
+
1893
+ AVG_VLOG(@"ASTC data: %lu bytes, %u bytes/layer (ASTC %ux%u), %u layers",
1894
+ (unsigned long)astcData.length, bytesPerLayer, self->_astcBlockWidth, self->_astcBlockHeight, layerCount);
1895
+
1896
+ // Pre-decode ASTC to RGBA on background thread for simulator (avoid main thread freeze)
1897
+ NSMutableArray<NSData *> *decodedLayers = nil;
1898
+ if (!self->_useCompressedFormat) {
1899
+ AVG_VLOG(@"Pre-decoding %u ASTC layers to RGBA on background thread (simulator)", layerCount);
1900
+ decodedLayers = [NSMutableArray arrayWithCapacity:layerCount];
1901
+ const uint8_t *astcBytes = (const uint8_t *)[astcData bytes];
1902
+
1903
+ for (uint32_t layer = 0; layer < layerCount; layer++) {
1904
+ const uint8_t *layerData = astcBytes + (layer * bytesPerLayer);
1905
+ NSData *layerASTCData = [NSData dataWithBytesNoCopy:(void *)layerData length:bytesPerLayer freeWhenDone:NO];
1906
+ NSData *rgbaData = [self decodeASTCToRGBA:layerASTCData width:width height:height];
1907
+ if (rgbaData) {
1908
+ [decodedLayers addObject:rgbaData];
1909
+ } else {
1910
+ // Create empty placeholder to maintain layer indices
1911
+ [decodedLayers addObject:[NSData data]];
1912
+ }
1913
+ }
1914
+ AVG_VLOG(@"Pre-decode complete, uploading to GPU");
1915
+ }
1916
+
1917
+ // Create texture array and upload on main thread (fast - just memcpy)
1918
+ dispatch_async(dispatch_get_main_queue(), ^{
1919
+ if (self->_isDestroyed) return;
1920
+
1921
+ // Create texture array descriptor
1922
+ // For non-streaming bulk load, use Private storage for better GPU read performance
1923
+ // Create staging texture with Shared storage, then blit to Private final texture
1924
+ MTLTextureDescriptor *descriptor = [[MTLTextureDescriptor alloc] init];
1925
+ descriptor.textureType = MTLTextureType2DArray;
1926
+ descriptor.pixelFormat = self->_useCompressedFormat ? self->_metalPixelFormat : MTLPixelFormatRGBA8Unorm;
1927
+ descriptor.width = width;
1928
+ descriptor.height = height;
1929
+ descriptor.arrayLength = layerCount;
1930
+ descriptor.mipmapLevelCount = 1;
1931
+ descriptor.usage = MTLTextureUsageShaderRead;
1932
+
1933
+ // Use Private storage for final texture (GPU-optimized layout)
1934
+ // Only use Private storage for compressed format (real device) - simulator needs Shared for CPU writes
1935
+ BOOL usePrivateStorage = self->_useCompressedFormat;
1936
+
1937
+ id<MTLTexture> stagingTexture = nil;
1938
+ if (usePrivateStorage) {
1939
+ // Create staging texture with Shared storage for CPU writes
1940
+ descriptor.storageMode = MTLStorageModeShared;
1941
+ stagingTexture = [self.device newTextureWithDescriptor:descriptor];
1942
+
1943
+ // Create final texture with Private storage for GPU-optimized reads
1944
+ descriptor.storageMode = MTLStorageModePrivate;
1945
+ self->_textureArray = [self.device newTextureWithDescriptor:descriptor];
1946
+ } else {
1947
+ descriptor.storageMode = MTLStorageModeShared;
1948
+ self->_textureArray = [self.device newTextureWithDescriptor:descriptor];
1949
+ }
1950
+
1951
+ if (!self->_textureArray || (usePrivateStorage && !stagingTexture)) {
1952
+ [self notifyError:@"Failed to create texture array"];
1953
+ return;
1954
+ }
1955
+
1956
+ // Upload each layer to staging texture (or directly to final if not using Private)
1957
+ id<MTLTexture> uploadTarget = usePrivateStorage ? stagingTexture : self->_textureArray;
1958
+ const uint8_t *astcBytes = (const uint8_t *)[astcData bytes];
1959
+
1960
+ for (uint32_t layer = 0; layer < layerCount; layer++) {
1961
+ MTLRegion region = MTLRegionMake2D(0, 0, width, height);
1962
+
1963
+ if (self->_useCompressedFormat) {
1964
+ // Upload compressed ASTC directly (real device)
1965
+ // bytesPerRow must be 0 for compressed textures per Apple docs
1966
+ const uint8_t *layerData = astcBytes + (layer * bytesPerLayer);
1967
+ [uploadTarget replaceRegion:region
1968
+ mipmapLevel:0
1969
+ slice:layer
1970
+ withBytes:layerData
1971
+ bytesPerRow:0
1972
+ bytesPerImage:bytesPerLayer];
1973
+ } else {
1974
+ // Upload pre-decoded RGBA (simulator) - uses Shared storage, no staging needed
1975
+ if (layer < decodedLayers.count && decodedLayers[layer].length > 0) {
1976
+ [uploadTarget replaceRegion:region
1977
+ mipmapLevel:0
1978
+ slice:layer
1979
+ withBytes:[decodedLayers[layer] bytes]
1980
+ bytesPerRow:width * 4
1981
+ bytesPerImage:width * height * 4];
1982
+ }
1983
+ }
1984
+ }
1985
+
1986
+ // Blit from staging to Private final texture if using Private storage
1987
+ if (usePrivateStorage && stagingTexture) {
1988
+ id<MTLCommandQueue> cmdQueue = self->_blitCommandQueue ?: [self.device newCommandQueue];
1989
+ id<MTLCommandBuffer> cmdBuf = [cmdQueue commandBuffer];
1990
+ id<MTLBlitCommandEncoder> blit = [cmdBuf blitCommandEncoder];
1991
+
1992
+ // Copy all layers from staging to final
1993
+ [blit copyFromTexture:stagingTexture toTexture:self->_textureArray];
1994
+
1995
+ [blit endEncoding];
1996
+ [cmdBuf commit];
1997
+ [cmdBuf waitUntilCompleted]; // Wait for blit to complete before releasing staging
1998
+
1999
+ // Staging texture will be released when it goes out of scope
2000
+ AVG_VLOG(@"Blitted staging texture to Private storage");
2001
+ }
2002
+
2003
+ self->_textureArrayLayerCount = layerCount;
2004
+ self->_isSourceLoaded = YES;
2005
+ self->_isSourceLoading = NO;
2006
+
2007
+ AVG_VLOG(@"✓ Texture array loaded: %ux%u, %u layers (Private storage: %@)",
2008
+ width, height, layerCount, usePrivateStorage ? @"YES" : @"NO");
2009
+
2010
+ [self notifyLoad];
2011
+ [self notifyStateChange:@"ready"];
2012
+
2013
+ if (self.autoPlay && !self.playbackPaused) {
2014
+ [self play];
2015
+ }
2016
+ });
2017
+ });
2018
+ }
2019
+
2020
+ #pragma mark - Internal Zstd Streaming
2021
+
2022
+ /**
2023
+ * Load a KTX2 texture array with internal zstd compression using streaming decompression.
2024
+ * The KTX2 header is uncompressed and only the level0 data is zstd compressed
2025
+ * (supercompressionScheme=2), so the layers are decompressed as they play.
2026
+ */
2027
+ - (void)loadInternalZstdStreamingTextureArray:(NSString *)filePath
2028
+ width:(uint32_t)width
2029
+ height:(uint32_t)height
2030
+ layerCount:(uint32_t)layerCount
2031
+ level0Offset:(uint64_t)level0Offset
2032
+ level0Length:(uint64_t)level0Length
2033
+ metadata:(NSDictionary *)metadata
2034
+ {
2035
+ AVG_VLOG(@"Starting internal zstd streaming: %@", filePath);
2036
+ AVG_VLOG(@"Internal zstd: %ux%u, %u layers, offset=%llu, compressedSize=%llu",
2037
+ width, height, layerCount, level0Offset, level0Length);
2038
+
2039
+ // Open file and mmap only the compressed data portion
2040
+ int fd = open([filePath UTF8String], O_RDONLY);
2041
+ if (fd < 0) {
2042
+ dispatch_async(dispatch_get_main_queue(), ^{
2043
+ [self notifyError:@"Failed to open file for internal zstd streaming"];
2044
+ });
2045
+ return;
2046
+ }
2047
+
2048
+ // Get file size for validation
2049
+ struct stat st;
2050
+ if (fstat(fd, &st) < 0) {
2051
+ close(fd);
2052
+ dispatch_async(dispatch_get_main_queue(), ^{
2053
+ [self notifyError:@"Failed to stat file"];
2054
+ });
2055
+ return;
2056
+ }
2057
+
2058
+ // Mmap the compressed data portion (page-aligned)
2059
+ long pageSize = sysconf(_SC_PAGE_SIZE);
2060
+ off_t alignedOffset = (level0Offset / pageSize) * pageSize;
2061
+ size_t offsetAdjustment = level0Offset - alignedOffset;
2062
+ size_t mmapLength = level0Length + offsetAdjustment;
2063
+
2064
+ void *mmapData = mmap(NULL, mmapLength, PROT_READ, MAP_PRIVATE, fd, alignedOffset);
2065
+ if (mmapData == MAP_FAILED) {
2066
+ close(fd);
2067
+ dispatch_async(dispatch_get_main_queue(), ^{
2068
+ [self notifyError:@"Failed to mmap internal zstd data"];
2069
+ });
2070
+ return;
2071
+ }
2072
+
2073
+ // Advise kernel for sequential access
2074
+ madvise(mmapData, mmapLength, MADV_SEQUENTIAL);
2075
+
2076
+ // Create ZSTD_DStream for streaming decompression
2077
+ ZSTD_DStream *dstream = ZSTD_createDStream();
2078
+ if (!dstream) {
2079
+ munmap(mmapData, mmapLength);
2080
+ close(fd);
2081
+ dispatch_async(dispatch_get_main_queue(), ^{
2082
+ [self notifyError:@"Failed to create ZSTD_DStream"];
2083
+ });
2084
+ return;
2085
+ }
2086
+
2087
+ size_t initResult = ZSTD_initDStream(dstream);
2088
+ if (ZSTD_isError(initResult)) {
2089
+ ZSTD_freeDStream(dstream);
2090
+ munmap(mmapData, mmapLength);
2091
+ close(fd);
2092
+ dispatch_async(dispatch_get_main_queue(), ^{
2093
+ [self notifyError:@"Failed to init ZSTD_DStream"];
2094
+ });
2095
+ return;
2096
+ }
2097
+
2098
+ const uint8_t *compressedDataPtr = (const uint8_t *)mmapData + offsetAdjustment;
2099
+ uint32_t bytesPerLayer = [self astcBytesPerLayerForWidth:width height:height];
2100
+ [self startInternalZstdStreaming:dstream
2101
+ width:width
2102
+ height:height
2103
+ layerCount:layerCount
2104
+ metadata:metadata
2105
+ adopt:^BOOL {
2106
+ self->_compressedData = [NSData dataWithBytesNoCopy:(void *)compressedDataPtr
2107
+ length:level0Length
2108
+ freeWhenDone:NO];
2109
+ self->_zstdStream = (AVGZstdStream){dstream, compressedDataPtr, (size_t)level0Length, 0, false};
2110
+ avg_zstd_seek_table_free(&self->_seekTable);
2111
+ if (avg_zstd_seek_table_parse(compressedDataPtr, (size_t)level0Length,
2112
+ (uint64_t)bytesPerLayer * layerCount, &self->_seekTable)) {
2113
+ AVG_VLOG(@"Seek table: %zu seek points", self->_seekTable.count);
2114
+ } else {
2115
+ AVG_VLOG(@"No seek table: seeks decode from the start");
2116
+ }
2117
+
2118
+ // Store mmap info for cleanup and loop restart
2119
+ self->_mmapFd = fd;
2120
+ self->_mmapData = mmapData;
2121
+ self->_mmapSize = mmapLength;
2122
+ return YES;
2123
+ }];
2124
+ }
2125
+
2126
+ /** loadInternalZstdStreamingTextureArray: for a level read through a reader as it plays. */
2127
+ - (void)loadInternalZstdStreamingFromReader:(AVGSourceReader *)reader
2128
+ width:(uint32_t)width
2129
+ height:(uint32_t)height
2130
+ layerCount:(uint32_t)layerCount
2131
+ level0Offset:(uint64_t)level0Offset
2132
+ level0Length:(uint64_t)level0Length
2133
+ metadata:(NSDictionary *)metadata
2134
+ {
2135
+ uint32_t bytesPerLayer = [self astcBytesPerLayerForWidth:width height:height];
2136
+ NSString *error = nil;
2137
+ AVGZstdSeekTable table;
2138
+ if (![self readSeekTable:&table
2139
+ from:reader
2140
+ levelOffset:level0Offset
2141
+ levelLength:level0Length
2142
+ decompressedLength:(uint64_t)bytesPerLayer * layerCount
2143
+ error:&error]) {
2144
+ dispatch_async(dispatch_get_main_queue(), ^{
2145
+ if (reader == self->_reader) [self notifyError:error];
2146
+ });
2147
+ return;
2148
+ }
2149
+ AVG_VLOG(@"%@", table.count > 0 ? [NSString stringWithFormat:@"Seek table: %zu seek points", table.count]
2150
+ : @"No seek table: seeks decode from the start");
2151
+
2152
+ ZSTD_DStream *dstream = ZSTD_createDStream();
2153
+ if (!dstream || ZSTD_isError(ZSTD_initDStream(dstream))) {
2154
+ ZSTD_freeDStream(dstream);
2155
+ avg_zstd_seek_table_free(&table);
2156
+ dispatch_async(dispatch_get_main_queue(), ^{
2157
+ [self notifyError:@"Failed to create ZSTD_DStream"];
2158
+ });
2159
+ return;
2160
+ }
2161
+ NSMutableData *chunk = [NSMutableData dataWithLength:kAVGReaderChunkBytes];
2162
+ __block AVGZstdSeekTable seekTable = table;
2163
+ [self startInternalZstdStreaming:dstream
2164
+ width:width
2165
+ height:height
2166
+ layerCount:layerCount
2167
+ metadata:metadata
2168
+ adopt:^BOOL {
2169
+ if (reader != self->_reader) {
2170
+ ZSTD_freeDStream(dstream);
2171
+ avg_zstd_seek_table_free(&seekTable);
2172
+ return NO;
2173
+ }
2174
+ self->_streamChunk = chunk;
2175
+ self->_levelInput.reader = (__bridge void *)reader;
2176
+ self->_levelInput.levelOffset = level0Offset;
2177
+ AVGZstdStream stream;
2178
+ memset(&stream, 0, sizeof(stream));
2179
+ stream.dstream = dstream;
2180
+ stream.size = (size_t)level0Length;
2181
+ stream.read = AVGReadLevel;
2182
+ stream.readContext = &self->_levelInput;
2183
+ stream.chunk = (uint8_t *)chunk.mutableBytes;
2184
+ stream.chunkCapacity = chunk.length;
2185
+ self->_zstdStream = stream;
2186
+ avg_zstd_seek_table_free(&self->_seekTable);
2187
+ self->_seekTable = seekTable;
2188
+ return YES;
2189
+ }];
2190
+ }
2191
+
2192
+ /**
2193
+ * The setup both zstd streams share, on the main queue: adopt installs the stream's input
2194
+ * (NO to drop it, for a source no longer showing), then the ring and the first frames.
2195
+ */
2196
+ - (void)startInternalZstdStreaming:(ZSTD_DStream *)dstream
2197
+ width:(uint32_t)width
2198
+ height:(uint32_t)height
2199
+ layerCount:(uint32_t)layerCount
2200
+ metadata:(NSDictionary *)metadata
2201
+ adopt:(BOOL (^)(void))adopt
2202
+ {
2203
+ // Calculate bytes per layer using current ASTC block dimensions
2204
+ uint32_t bytesPerLayer = [self astcBytesPerLayerForWidth:width height:height];
2205
+
2206
+ // vram decodes into the ring: the shown frame, the ones decoded after it, and the previous
2207
+ // frame, which the GPU may still be drawing. ram uploads each frame as it's shown.
2208
+ BOOL useVramStrategy = ![self.memoryStrategy isEqualToString:@"ram"];
2209
+ NSInteger gpuCacheSize = MIN(useVramStrategy ? self.streamingPrefetchAhead + 2 : kRamRingSize,
2210
+ (NSInteger)layerCount);
2211
+
2212
+ AVG_VLOG(@"Internal zstd streaming params: strategy=%@, gpuCache=%ld, bytesPerLayer=%u (ASTC %ux%u)",
2213
+ self.memoryStrategy, (long)gpuCacheSize, bytesPerLayer, _astcBlockWidth, _astcBlockHeight);
2214
+
2215
+ // Setup on main thread
2216
+ dispatch_sync(dispatch_get_main_queue(), ^{
2217
+ if (!adopt()) return;
2218
+
2219
+ // Apply metadata
2220
+ self.totalFrames = layerCount;
2221
+ self.frameWidth = width;
2222
+ self.frameHeight = height;
2223
+ self.frameRate = [metadata[@"frameRate"] doubleValue];
2224
+ self.frameInterval = 1.0 / self.frameRate;
2225
+
2226
+ self->_textureArrayLayerCount = layerCount;
2227
+ self->_streamingRingBufferMode = YES;
2228
+ self->_streamingRingBufferSize = gpuCacheSize;
2229
+ self->_useVramStrategy = useVramStrategy;
2230
+
2231
+ // Initialize ring buffer tracking
2232
+ self->_ringSlots = [NSMutableArray arrayWithCapacity:gpuCacheSize];
2233
+ for (NSInteger i = 0; i < gpuCacheSize; i++) {
2234
+ [self->_ringSlots addObject:@(-1)];
2235
+ }
2236
+ self->_frameCache = [NSMutableDictionary dictionary];
2237
+ self->_decoderPosition = 0;
2238
+ self->_decoderLoopIteration = 0;
2239
+ self->_playbackLoopIteration = 0;
2240
+ self->_fewestAhead = layerCount;
2241
+ self->_stalls = 0;
2242
+
2243
+ self->_dstream = dstream;
2244
+ self->_pendingSkipBytes = 0;
2245
+ self->_frameAccumulator = [NSMutableData data];
2246
+ self->_streamBytesPerLayer = bytesPerLayer;
2247
+ self->_streamLevel0Offset = 0; // the stream's offsets start at the level0 data
2248
+
2249
+ // Create texture array with ring buffer size
2250
+ MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:self->_metalPixelFormat
2251
+ width:width
2252
+ height:height
2253
+ mipmapped:NO];
2254
+ descriptor.textureType = MTLTextureType2DArray;
2255
+ descriptor.arrayLength = gpuCacheSize;
2256
+ descriptor.usage = MTLTextureUsageShaderRead;
2257
+
2258
+ self->_textureArray = [self.device newTextureWithDescriptor:descriptor];
2259
+
2260
+ if (!self->_textureArray) {
2261
+ [self notifyError:@"Failed to create texture array for internal zstd streaming"];
2262
+ return;
2263
+ }
2264
+
2265
+ AVG_VLOG(@"Created internal zstd streaming texture array: %ux%u, %ld GPU slots",
2266
+ width, height, (long)gpuCacheSize);
2267
+
2268
+ // Pre-decode initial frames
2269
+ [self decodeFramesAhead:0];
2270
+
2271
+ self->_isSourceLoaded = YES;
2272
+ self->_isSourceLoading = NO;
2273
+ [self notifyLoad];
2274
+ [self notifyStateChange:@"ready"];
2275
+
2276
+ if (self.autoPlay && !self.playbackPaused) {
2277
+ [self play];
2278
+ }
2279
+ });
2280
+
2281
+ AVG_VLOG(@"Internal zstd streaming setup complete");
2282
+ }
2283
+
2284
+ - (void)play
2285
+ {
2286
+ AVG_VLOG(@"play called - isPlaying=%d, totalFrames=%ld", self.isPlaying, (long)self.totalFrames);
2287
+
2288
+ if (!self.isPlaying && self.totalFrames > 0) {
2289
+ AVG_VLOG(@"Starting playback...");
2290
+ if (_ended) {
2291
+ [self seekToFrame:0];
2292
+ }
2293
+ self.isPlaying = YES;
2294
+ self.lastFrameTime = CACurrentMediaTime();
2295
+ [self updateDisplayLink];
2296
+
2297
+ [self notifyStateChange:@"playing"];
2298
+ }
2299
+ }
2300
+
2301
+ - (void)pause
2302
+ {
2303
+ if (self.isPlaying) {
2304
+ self.isPlaying = NO;
2305
+ [self updateDisplayLink];
2306
+ [self notifyStateChange:@"paused"];
2307
+ }
2308
+ }
2309
+
2310
+ - (void)stop
2311
+ {
2312
+ [self pause];
2313
+ [self seekToFrame:0];
2314
+ [self notifyStateChange:@"stopped"];
2315
+ }
2316
+
2317
+ - (void)seekToFrame:(NSInteger)frame
2318
+ {
2319
+ if (!_isSourceLoaded || self.totalFrames <= 0) return;
2320
+ NSInteger target = MAX(0, MIN(frame, self.totalFrames - 1));
2321
+ _ended = NO;
2322
+ if (_streamingRingBufferMode) {
2323
+ [self moveStreamTo:target];
2324
+ }
2325
+ self.currentFrame = target;
2326
+ self.lastFrameTime = CACurrentMediaTime();
2327
+ _frameOwed = YES;
2328
+ [self drawOwedFrame];
2329
+ [self updateDisplayLink];
2330
+ [self notifyFrameUpdate];
2331
+ }
2332
+
2333
+ - (void)seekToTime:(NSTimeInterval)seconds
2334
+ {
2335
+ double frame = floor(MAX(seconds, 0) * self.frameRate + 1e-6);
2336
+ [self seekToFrame:(NSInteger)MIN(frame, (double)INT32_MAX)];
2337
+ }
2338
+
2339
+ /** Draws the frame a load or a seek owes, once it is decoded. */
2340
+ - (void)drawOwedFrame
2341
+ {
2342
+ if (!_frameOwed || ![self prepareShownFrame]) return;
2343
+ _frameOwed = NO;
2344
+ [self setNeedsDisplay];
2345
+ }
2346
+
2347
+ /** Runs the display link while playing, or until an owed frame is drawn. */
2348
+ - (void)updateDisplayLink
2349
+ {
2350
+ BOOL needed = self.isPlaying || _frameOwed;
2351
+ if (needed && !self.displayLink) {
2352
+ self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateFrame)];
2353
+ self.displayLink.preferredFramesPerSecond = (NSInteger)ceil(self.frameRate);
2354
+ [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
2355
+ } else if (!needed && self.displayLink) {
2356
+ [self.displayLink invalidate];
2357
+ self.displayLink = nil;
2358
+ }
2359
+ }
2360
+
2361
+ // Same range as Android and the web
2362
+ - (void)setStreamingPrefetchAhead:(NSInteger)frames
2363
+ {
2364
+ _streamingPrefetchAhead = MAX(1, MIN(100, frames));
2365
+ }
2366
+
2367
+ - (void)setPlaybackSpeed:(CGFloat)speed
2368
+ {
2369
+ _playbackSpeed = speed;
2370
+ _frameInterval = 1.0 / (_frameRate * speed);
2371
+ }
2372
+
2373
+ - (void)setPlaybackPaused:(BOOL)newPaused
2374
+ {
2375
+ if (_playbackPaused != newPaused) {
2376
+ _playbackPaused = newPaused;
2377
+ AVG_VLOG(@"setPlaybackPaused: %d", newPaused);
2378
+
2379
+ if (newPaused) {
2380
+ // Stop playback
2381
+ [self pause];
2382
+ } else {
2383
+ // Resume playback if we were playing before and source is loaded
2384
+ if (_isSourceLoaded && self.autoPlay && !self.isPlaying) {
2385
+ [self play];
2386
+ }
2387
+ }
2388
+ }
2389
+ }
2390
+
2391
+ - (void)updateFrame
2392
+ {
2393
+ if (_frameOwed) {
2394
+ [self drawOwedFrame];
2395
+ if (!_frameOwed) self.lastFrameTime = CACurrentMediaTime();
2396
+ [self updateDisplayLink];
2397
+ return;
2398
+ }
2399
+ if (!self.isPlaying) {
2400
+ [self updateDisplayLink];
2401
+ return;
2402
+ }
2403
+
2404
+ CFTimeInterval currentTime = CACurrentMediaTime();
2405
+
2406
+ CFTimeInterval elapsed = currentTime - self.lastFrameTime;
2407
+
2408
+ if (elapsed >= self.frameInterval / self.playbackSpeed) {
2409
+ self.lastFrameTime = currentTime - fmod(elapsed, self.frameInterval / self.playbackSpeed);
2410
+
2411
+ // For streaming ring buffer mode, handle on-demand decoding/upload
2412
+ if (_streamingRingBufferMode) {
2413
+ NSInteger vPosModulus = self.totalFrames * 2;
2414
+ NSInteger virtualPos = (_playbackLoopIteration * self.totalFrames + self.currentFrame) % vPosModulus;
2415
+ NSInteger gpuSlot = self.currentFrame % _streamingRingBufferSize;
2416
+
2417
+ if (_isUncompressedStreaming) {
2418
+ // Uncompressed streaming
2419
+ if (!_useCompressedFormat && _decodedRgbaCache) {
2420
+ // Simulator: use background-decoded RGBA cache
2421
+ [self decodeUncompressedFramesAhead:virtualPos];
2422
+ [self evictOldDecodedFrames:virtualPos];
2423
+
2424
+ NSInteger slotVPos = [_ringSlots[gpuSlot] integerValue];
2425
+ if (slotVPos != virtualPos) {
2426
+ NSData *rgbaData;
2427
+ @synchronized(_decodedRgbaCache) {
2428
+ rgbaData = _decodedRgbaCache[@(virtualPos)];
2429
+ }
2430
+ if (rgbaData) {
2431
+ [self uploadDecodedFrameToGPU:virtualPos slot:gpuSlot];
2432
+ _ringSlots[gpuSlot] = @(virtualPos);
2433
+ } else {
2434
+ // Frame not decoded yet - skip this update
2435
+ return;
2436
+ }
2437
+ }
2438
+ } else {
2439
+ // Real device: upload directly from mmap (fast, native ASTC)
2440
+ NSInteger slotVPos = [_ringSlots[gpuSlot] integerValue];
2441
+ if (slotVPos != virtualPos) {
2442
+ [self uploadUncompressedFrame:self.currentFrame toSlot:gpuSlot];
2443
+ _ringSlots[gpuSlot] = @(virtualPos);
2444
+ }
2445
+ }
2446
+ } else {
2447
+ // Zstd streaming: decode frames ahead
2448
+ [self decodeFramesAhead:virtualPos];
2449
+
2450
+ // Evict old frames from CPU cache
2451
+ [self evictOldFrames:virtualPos];
2452
+
2453
+ // Ensure current frame is in GPU
2454
+ [self ensureFrameInGPU:virtualPos];
2455
+
2456
+ // Check if frame is ready
2457
+ NSInteger slotVPos = [_ringSlots[gpuSlot] integerValue];
2458
+
2459
+ if (slotVPos != virtualPos && !_frameCache[@(virtualPos)]) {
2460
+ // Frame not ready yet - skip this update
2461
+ if (!_stalled) {
2462
+ _stalled = YES;
2463
+ _stalls++;
2464
+ }
2465
+ static int notReadyCount = 0;
2466
+ if (notReadyCount++ < 20) {
2467
+ AVG_VLOG(@"Frame not ready - frame=%ld, vPos=%ld, slot=%ld has vPos=%ld, cacheSize=%lu, decoderPos=%ld",
2468
+ (long)self.currentFrame, (long)virtualPos, (long)gpuSlot, (long)slotVPos,
2469
+ (unsigned long)_frameCache.count, (long)_decoderPosition);
2470
+ }
2471
+ return;
2472
+ }
2473
+ _stalled = NO;
2474
+ NSInteger decodeVPos = (_decoderLoopIteration * self.totalFrames + _decoderPosition) % vPosModulus;
2475
+ _fewestAhead = MIN(_fewestAhead, (decodeVPos - virtualPos + vPosModulus) % vPosModulus - 1);
2476
+ }
2477
+ }
2478
+
2479
+ self.currentFrame++;
2480
+
2481
+ if (self.currentFrame >= self.totalFrames) {
2482
+ if (_streamingRingBufferMode && !_isUncompressedStreaming) {
2483
+ AVG_VLOG(@"Pass buffer: fewest frames ahead %ld of %ld, stalls %ld",
2484
+ (long)_fewestAhead, (long)self.streamingPrefetchAhead, (long)_stalls);
2485
+ _fewestAhead = self.totalFrames;
2486
+ _stalls = 0;
2487
+ }
2488
+ if (self.loop) {
2489
+ self.currentFrame = 0;
2490
+ if (_streamingRingBufferMode) {
2491
+ _playbackLoopIteration++;
2492
+ NSInteger nextVPos = (_playbackLoopIteration * self.totalFrames) % (self.totalFrames * 2);
2493
+ AVG_VLOG(@"Playback loop wrap-around: iteration %ld, nextVPos=%ld, decoderLoop=%ld, decoderPos=%ld, cacheSize=%lu",
2494
+ (long)_playbackLoopIteration, (long)nextVPos, (long)_decoderLoopIteration, (long)_decoderPosition, (unsigned long)_frameCache.count);
2495
+ // Log first few slot states
2496
+ for (int i = 0; self.verbose && i < MIN(4, _streamingRingBufferSize); i++) {
2497
+ AVG_VLOG(@" slot[%d] = vPos %ld", i, (long)[_ringSlots[i] integerValue]);
2498
+ }
2499
+ // Log if next frame is in cache
2500
+ AVG_VLOG(@" cache has nextVPos=%ld: %@", (long)nextVPos, _frameCache[@(nextVPos)] ? @"YES" : @"NO");
2501
+
2502
+ // CRITICAL: Immediately upload frame 0 of new loop to GPU before next render
2503
+ // This prevents showing stale frame from previous loop
2504
+ [self ensureFrameInGPU:nextVPos];
2505
+ }
2506
+ } else {
2507
+ // The last frame stays on screen
2508
+ self.currentFrame = self.totalFrames - 1;
2509
+ _ended = YES;
2510
+ self.isPlaying = NO;
2511
+ [self updateDisplayLink];
2512
+ [self notifyFinished];
2513
+ [self notifyStateChange:@"stopped"];
2514
+ return;
2515
+ }
2516
+ }
2517
+
2518
+ if (self.enableFrameEvents) {
2519
+ [self notifyFrameUpdate];
2520
+ }
2521
+ [self setNeedsDisplay];
2522
+ }
2523
+ }
2524
+
2525
+ - (void)drawInMTKView:(MTKView *)view
2526
+ {
2527
+ static int drawCount = 0;
2528
+
2529
+ if (_textureArray && _arrayPipelineState) {
2530
+ if (drawCount < 5) {
2531
+ AVG_VLOG(@"drawInMTKView - rendering texture array frame %ld", (long)self.currentFrame);
2532
+ drawCount++;
2533
+ }
2534
+
2535
+ // Update layer uniform - use ring buffer slot for streaming mode
2536
+ float layer;
2537
+ if (_streamingRingBufferMode) {
2538
+ NSInteger gpuSlot = self.currentFrame % _streamingRingBufferSize;
2539
+
2540
+ // CRITICAL: Ensure the GPU slot has the correct frame before rendering
2541
+ // This handles the loop boundary case where the slot has stale data
2542
+ NSInteger vPosModulus = self.totalFrames * 2;
2543
+ NSInteger virtualPos = (_playbackLoopIteration * self.totalFrames + self.currentFrame) % vPosModulus;
2544
+ NSInteger slotVPos = [_ringSlots[gpuSlot] integerValue];
2545
+
2546
+ if (slotVPos != virtualPos) {
2547
+ // Slot has wrong frame - upload correct frame
2548
+ if (_isUncompressedStreaming) {
2549
+ if (!_useCompressedFormat && _decodedRgbaCache) {
2550
+ // Simulator: upload from decoded RGBA cache
2551
+ NSData *rgbaData;
2552
+ @synchronized(_decodedRgbaCache) {
2553
+ rgbaData = _decodedRgbaCache[@(virtualPos)];
2554
+ }
2555
+ if (rgbaData) {
2556
+ [self uploadDecodedFrameToGPU:virtualPos slot:gpuSlot];
2557
+ _ringSlots[gpuSlot] = @(virtualPos);
2558
+ }
2559
+ } else {
2560
+ // Real device: upload directly from mmap
2561
+ [self uploadUncompressedFrame:self.currentFrame toSlot:gpuSlot];
2562
+ _ringSlots[gpuSlot] = @(virtualPos);
2563
+ }
2564
+ } else {
2565
+ // Zstd: upload from cache
2566
+ NSData *frameData = _frameCache[@(virtualPos)];
2567
+ if (frameData) {
2568
+ [self uploadFrameToGPU:frameData.bytes slot:gpuSlot];
2569
+ _ringSlots[gpuSlot] = @(virtualPos);
2570
+ }
2571
+ }
2572
+ }
2573
+
2574
+ layer = (float)gpuSlot;
2575
+ } else {
2576
+ layer = (float)self.currentFrame;
2577
+ }
2578
+ memcpy([_arrayUniformBuffer contents], &layer, sizeof(float));
2579
+
2580
+ id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
2581
+ MTLRenderPassDescriptor *renderPassDescriptor = view.currentRenderPassDescriptor;
2582
+
2583
+ if (renderPassDescriptor) {
2584
+ id<MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
2585
+
2586
+ [renderEncoder setRenderPipelineState:_arrayPipelineState];
2587
+ [renderEncoder setFragmentTexture:_textureArray atIndex:0];
2588
+ [renderEncoder setFragmentSamplerState:_samplerState atIndex:0];
2589
+ [renderEncoder setFragmentBuffer:_arrayUniformBuffer offset:0 atIndex:0];
2590
+
2591
+ [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6];
2592
+ [renderEncoder endEncoding];
2593
+
2594
+ [commandBuffer presentDrawable:view.currentDrawable];
2595
+ }
2596
+
2597
+ [commandBuffer commit];
2598
+ }
2599
+ }
2600
+
2601
+ - (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size
2602
+ {
2603
+ // Handle view size changes if needed
2604
+ }
2605
+
2606
+ #pragma mark - Lifecycle
2607
+
2608
+ - (void)cleanup
2609
+ {
2610
+ AVG_VLOG(@"cleanup");
2611
+ _isDestroyed = YES;
2612
+ _isSourceLoaded = NO;
2613
+ _isSourceLoading = NO;
2614
+ _frameOwed = NO;
2615
+ _ended = NO;
2616
+
2617
+ // Stop playback
2618
+ self.isPlaying = NO;
2619
+ if (self.displayLink) {
2620
+ [self.displayLink invalidate];
2621
+ self.displayLink = nil;
2622
+ }
2623
+
2624
+ _textureArray = nil;
2625
+
2626
+ // Cleanup streaming state
2627
+ if (_dstream) {
2628
+ ZSTD_freeDStream(_dstream);
2629
+ _dstream = NULL;
2630
+ }
2631
+ _compressedData = nil;
2632
+ memset(&_zstdStream, 0, sizeof(_zstdStream));
2633
+ avg_zstd_seek_table_free(&_seekTable);
2634
+ _streamChunk = nil;
2635
+ memset(&_levelInput, 0, sizeof(_levelInput));
2636
+ _streamFailed = NO;
2637
+ // Reads still waiting on it, for a load in flight, fail; the load then drops them
2638
+ [_reader close];
2639
+ _reader = nil;
2640
+ _pendingSkipBytes = 0;
2641
+ _frameAccumulator = nil;
2642
+ _ringSlots = nil;
2643
+ _frameCache = nil;
2644
+ _streamingRingBufferMode = NO;
2645
+
2646
+ // Cleanup mmap. The simulator's ASTC decode blocks read the mapping on _astcDecodeQueue and
2647
+ // stop once _mmapData is NULL, so unmap after the block in flight, on the same serial queue.
2648
+ void *mmapData = _mmapData;
2649
+ size_t mmapSize = _mmapSize;
2650
+ int mmapFd = _mmapFd;
2651
+ _mmapData = NULL;
2652
+ _mmapFd = -1;
2653
+ _mmapSize = 0;
2654
+ NSData *heldFileData = _heldFileData;
2655
+ _heldFileData = nil;
2656
+ if (heldFileData) {
2657
+ // Not a mapping: released after the block in flight
2658
+ dispatch_async(_astcDecodeQueue, ^{
2659
+ (void)heldFileData;
2660
+ });
2661
+ } else if ((mmapData && mmapData != MAP_FAILED) || mmapFd >= 0) {
2662
+ dispatch_async(_astcDecodeQueue, ^{
2663
+ if (mmapData && mmapData != MAP_FAILED) munmap(mmapData, mmapSize);
2664
+ if (mmapFd >= 0) close(mmapFd);
2665
+ });
2666
+ }
2667
+ _isUncompressedStreaming = NO;
2668
+
2669
+ // Cleanup staging buffers for async blit
2670
+ _stagingBuffers[0] = nil;
2671
+ _stagingBuffers[1] = nil;
2672
+ _blitCommandQueue = nil;
2673
+ }
2674
+
2675
+ - (void)willMoveToSuperview:(UIView *)newSuperview
2676
+ {
2677
+ [super willMoveToSuperview:newSuperview];
2678
+
2679
+ if (newSuperview == nil) {
2680
+ // View is being removed - cleanup
2681
+ AVG_VLOG(@"willMoveToSuperview nil - cleaning up");
2682
+ [self cleanup];
2683
+ } else {
2684
+ // View is being added - restart if we have a source
2685
+ AVG_VLOG(@"willMoveToSuperview - view added");
2686
+ _isDestroyed = NO;
2687
+
2688
+ // If we have a source but it's not loaded or loading, reload it
2689
+ if (_source && !_isSourceLoaded && !_isSourceLoading) {
2690
+ AVG_VLOG(@"Restarting playback after view re-added");
2691
+ _isSourceLoading = YES;
2692
+ if (_localPath) {
2693
+ [self loadKTX2FromFile:_localPath];
2694
+ } else {
2695
+ [self resolveAndLoadSource];
2696
+ }
2697
+ }
2698
+ }
2699
+ }
2700
+
2701
+ - (void)didMoveToWindow
2702
+ {
2703
+ [super didMoveToWindow];
2704
+
2705
+ if (self.window == nil) {
2706
+ // View removed from window - pause but don't cleanup yet
2707
+ AVG_VLOG(@"didMoveToWindow nil - pausing");
2708
+ [self pause];
2709
+ } else if (_source && _isSourceLoaded && self.autoPlay && !self.playbackPaused && !self.isPlaying) {
2710
+ // View added to window and we have loaded source - resume
2711
+ AVG_VLOG(@"didMoveToWindow - resuming playback");
2712
+ [self play];
2713
+ }
2714
+ }
2715
+
2716
+ - (void)dealloc
2717
+ {
2718
+ AVG_VLOG(@"dealloc");
2719
+ [self cleanup];
2720
+ }
2721
+
2722
+ @end