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

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 +953 -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 +78 -4
  29. package/react-native/android/build.gradle +95 -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 +296 -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,2126 @@
1
+ package dev.mediacache.alphavideo.gpu;
2
+
3
+ import android.content.Context;
4
+ import android.content.res.AssetFileDescriptor;
5
+ import android.graphics.SurfaceTexture;
6
+ import android.net.Uri;
7
+ import android.opengl.EGL14;
8
+ import android.opengl.EGLConfig;
9
+ import android.opengl.EGLContext;
10
+ import android.opengl.EGLDisplay;
11
+ import android.opengl.EGLSurface;
12
+ import android.opengl.GLES30;
13
+ import android.os.Handler;
14
+ import android.os.Looper;
15
+ import android.util.AttributeSet;
16
+ import android.util.Log;
17
+ import android.view.TextureView;
18
+
19
+ import androidx.annotation.Nullable;
20
+
21
+
22
+ import java.io.File;
23
+ import java.util.Arrays;
24
+ import java.util.Collections;
25
+ import java.util.LinkedHashMap;
26
+ import java.util.Map;
27
+ import java.util.Objects;
28
+ import java.io.IOException;
29
+ import java.nio.ByteBuffer;
30
+ import java.nio.ByteOrder;
31
+ import java.nio.FloatBuffer;
32
+ import java.util.concurrent.ExecutorService;
33
+ import java.util.concurrent.Executors;
34
+ import java.util.concurrent.atomic.AtomicBoolean;
35
+ import java.util.concurrent.atomic.AtomicInteger;
36
+
37
+ /**
38
+ * Plays transparent animations stored as ASTC KTX2 texture arrays, one frame per layer, drawn
39
+ * with OpenGL ES 3.0. The layers are zstd-supercompressed or uncompressed. Without ASTC support
40
+ * (emulators) frames are decoded to RGBA in software.
41
+ *
42
+ * A TextureView rather than a GLSurfaceView, so it stacks and blends with the views around it.
43
+ * Call its setters and playback methods on the main thread; {@link PlayerCallback} methods are
44
+ * called there too.
45
+ */
46
+ public class AlphaVideoGpuView extends TextureView implements TextureView.SurfaceTextureListener {
47
+ private static final String TAG = "AlphaVideoGpuView";
48
+
49
+ /** Player events. Every method has an empty default, so implement only what you need. */
50
+ public interface PlayerCallback {
51
+ /** The animation is loaded: frame size in pixels, frame count and frames per second. */
52
+ default void onLoad(int width, int height, int frameCount, double frameRate) {}
53
+ /** The shown frame changed, after seekToFrame, and every frame when frame events are on. */
54
+ default void onFrameUpdate(int frame, int totalFrames) {}
55
+ /** "loading", "ready", "playing", "paused" or "stopped". */
56
+ default void onStateChange(String state) {}
57
+ /** Playback reached the last frame with looping off. */
58
+ default void onFinished() {}
59
+ default void onError(String error) {}
60
+ /** An error with its code, such as {@link #ERROR_NO_SOURCE_MODULE}, or null. Calls {@link #onError(String)} unless overridden. */
61
+ default void onError(String error, @Nullable String code) {
62
+ onError(error);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * {@link PlayerCallback#onError(String, String)}'s code for a data source
68
+ * ({@link #setDataSource}) without the range-source add-on, which reads them.
69
+ */
70
+ public static final String ERROR_NO_SOURCE_MODULE = "NO_SOURCE_MODULE";
71
+ // A URL played without the add-on is warned of once per process
72
+ private static final AtomicBoolean warnedWithoutAddOn = new AtomicBoolean(false);
73
+
74
+ // GL constants - ASTC formats for all 14 block sizes
75
+ private static final int GL_COMPRESSED_RGBA_ASTC_4x4_KHR = 0x93B0;
76
+ private static final int GL_COMPRESSED_RGBA_ASTC_5x4_KHR = 0x93B1;
77
+ private static final int GL_COMPRESSED_RGBA_ASTC_5x5_KHR = 0x93B2;
78
+ private static final int GL_COMPRESSED_RGBA_ASTC_6x5_KHR = 0x93B3;
79
+ private static final int GL_COMPRESSED_RGBA_ASTC_6x6_KHR = 0x93B4;
80
+ private static final int GL_COMPRESSED_RGBA_ASTC_8x5_KHR = 0x93B5;
81
+ private static final int GL_COMPRESSED_RGBA_ASTC_8x6_KHR = 0x93B6;
82
+ private static final int GL_COMPRESSED_RGBA_ASTC_8x8_KHR = 0x93B7;
83
+ private static final int GL_COMPRESSED_RGBA_ASTC_10x5_KHR = 0x93B8;
84
+ private static final int GL_COMPRESSED_RGBA_ASTC_10x6_KHR = 0x93B9;
85
+ private static final int GL_COMPRESSED_RGBA_ASTC_10x8_KHR = 0x93BA;
86
+ private static final int GL_COMPRESSED_RGBA_ASTC_10x10_KHR = 0x93BB;
87
+ private static final int GL_COMPRESSED_RGBA_ASTC_12x10_KHR = 0x93BC;
88
+ private static final int GL_COMPRESSED_RGBA_ASTC_12x12_KHR = 0x93BD;
89
+
90
+ // How long the zstd stream decoder catches up to a seek before the render loop runs again
91
+ private static final long SKIP_SLICE_NANOS = 16_000_000L;
92
+
93
+ private PlayerCallback playerCallback;
94
+
95
+ // Where what a reader fetches is kept: cacheDir/alpha-video-gpu on disk, or the memory store's name
96
+ private static final String STORE_NAME = "alpha-video-gpu";
97
+ public static final long DEFAULT_MAX_STORAGE_BYTES = 512L * 1024 * 1024;
98
+ public static final String STORAGE_AUTO = "auto";
99
+ public static final String STORAGE_DISK = "disk";
100
+ public static final String STORAGE_MEMORY = "memory";
101
+ public static final String STORAGE_NONE = "none";
102
+
103
+ // Source
104
+ private String mSource;
105
+ private String mCacheKey;
106
+ private Map<String, String> mHeaders = Collections.emptyMap();
107
+ private String mDataSource;
108
+ private String mStorage = STORAGE_AUTO;
109
+ private long mMaxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
110
+ // The cache key the currently running playback was started for. Compared against
111
+ // mCacheKey to tell a re-signed URL for the same object apart from a new asset.
112
+ private String mPlayingCacheKey;
113
+ // What the running playback reads with, taken when it starts
114
+ private Map<String, String> mPlayingHeaders = Collections.emptyMap();
115
+ private String mPlayingDataSource;
116
+ private volatile boolean mSourceLoaded = false;
117
+
118
+ // Playback settings
119
+ private boolean autoPlay = true;
120
+ private boolean loop = true;
121
+ private float playbackSpeed = 1.0f;
122
+ private boolean enableFrameEvents = false; // Disabled by default for performance
123
+ private final AtomicBoolean isPaused = new AtomicBoolean(false); // setPaused, kept apart from pause()
124
+
125
+ // Animation state
126
+ private int currentFrame = 0;
127
+ private volatile int totalFrames = 0;
128
+ // The frame the render loop shows next, set from the main thread; -1 when there is none
129
+ private final AtomicInteger seekRequest = new AtomicInteger(-1);
130
+ // A clip that doesn't loop has reached its last frame; playing again starts it over
131
+ private volatile boolean ended = false;
132
+ private double frameRate = Ktx2Metadata.DEFAULT_FRAME_RATE;
133
+
134
+ private int frameWidth = 0;
135
+ private int frameHeight = 0;
136
+
137
+ private volatile boolean verbose = false;
138
+
139
+
140
+ // OpenGL resources
141
+ private EGLDisplay eglDisplay;
142
+ private EGLContext eglContext;
143
+ private EGLSurface eglSurface;
144
+
145
+ private FloatBuffer vertexBuffer;
146
+ private FloatBuffer texCoordBuffer;
147
+
148
+ // The GPU samples ASTC; without it (emulators) frames are decoded to RGBA in software
149
+ private boolean astcSupported = false;
150
+
151
+ // ASTC block dimensions and GL format (derived from vkFormat, default 4x4)
152
+ private int astcBlockWidth = 4;
153
+ private int astcBlockHeight = 4;
154
+ private int astcGlFormat = GL_COMPRESSED_RGBA_ASTC_4x4_KHR;
155
+
156
+ private int textureArrayId = 0;
157
+ private int textureArrayLayerCount = 0;
158
+ private boolean streamingRingBufferMode = false; // True when using ring buffer for streaming
159
+ private int streamingRingSize = 0; // Size of ring buffer (for layer index calculation)
160
+ private boolean pbosInitialized = false; // True when PBOs are initialized for async uploads
161
+
162
+ // RAM-heavy mode storage (for memoryStrategy: 'ram' in non-stream mode)
163
+ // Uses contiguous direct ByteBuffer for zero-copy GPU uploads
164
+ private ByteBuffer ramFrameBuffer = null; // All decoded frames in single contiguous buffer
165
+ private int ramBytesPerFrame = 0; // Size of each frame in bytes
166
+ private int ramFrameCount = 0; // Total number of frames
167
+ private boolean ramHeavyMode = false; // True when using RAM-heavy non-stream mode
168
+ private int ramUploadedFrame = -1; // Currently uploaded frame in GPU ring buffer
169
+
170
+ // Frames decoded ahead of the shown one while streaming (set via props). Streaming keeps
171
+ // only these, so it also sizes the GPU ring (vram) or the CPU cache (ram).
172
+ private int configStreamingPrefetchAhead = 8;
173
+ // The ram strategy's GPU ring: the previous frame, the shown one and the next two
174
+ private static final int RAM_RING_SIZE = 4;
175
+
176
+ // Memory strategy: "vram", "ram"
177
+ // - vram (default): frames in GPU VRAM (best for many simultaneous players)
178
+ // - ram: Decoded frames in RAM, upload per-frame (lower VRAM usage)
179
+ private String memoryStrategy = "vram";
180
+
181
+ // Stream prop - when true, use streaming decompression for texture arrays
182
+ private boolean streamProp = true;
183
+
184
+ // Threading
185
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
186
+ private ExecutorService executor;
187
+ private final AtomicBoolean isPlaying = new AtomicBoolean(false);
188
+ private final AtomicBoolean shouldStop = new AtomicBoolean(false);
189
+ private final AtomicBoolean isDestroyed = new AtomicBoolean(false);
190
+ private final AtomicBoolean isPlaybackRunning = new AtomicBoolean(false); // Guards against concurrent startPlayback calls
191
+ // Set when startPlayback() is called while a previous worker is still unwinding.
192
+ // stopPlayback() only raises a flag, so the worker exits asynchronously; without
193
+ // recording the owed restart the view would stay stopped until the surface was
194
+ // destroyed and recreated (i.e. until the user navigated away and back).
195
+ private final AtomicBoolean pendingRestart = new AtomicBoolean(false);
196
+
197
+ // Shaders
198
+ private static final String TEXTURE_ARRAY_FRAGMENT_SHADER =
199
+ "#version 300 es\n" +
200
+ "#ifdef GL_FRAGMENT_PRECISION_HIGH\n" +
201
+ "precision highp float;\n" +
202
+ "precision highp sampler2DArray;\n" +
203
+ "#else\n" +
204
+ "precision mediump float;\n" +
205
+ "precision mediump sampler2DArray;\n" +
206
+ "#endif\n" +
207
+ "in vec2 vTexCoord;\n" +
208
+ "out vec4 fragColor;\n" +
209
+ "uniform sampler2DArray uTextureArray;\n" +
210
+ "uniform float uLayer;\n" +
211
+ "void main() {\n" +
212
+ " fragColor = texture(uTextureArray, vec3(vTexCoord, uLayer));\n" +
213
+ "}\n";
214
+
215
+ private static final String TEXTURE_ARRAY_VERTEX_SHADER =
216
+ "#version 300 es\n" +
217
+ "in vec4 aPosition;\n" +
218
+ "in vec2 aTexCoord;\n" +
219
+ "out vec2 vTexCoord;\n" +
220
+ "void main() {\n" +
221
+ " gl_Position = aPosition;\n" +
222
+ " vTexCoord = aTexCoord;\n" +
223
+ "}\n";
224
+
225
+ // Texture array shader program and uniforms
226
+ private int arrayProgram = 0;
227
+ private int arrayUniformLayerLocation = -1;
228
+
229
+ private static final float[] VERTEX_DATA = {
230
+ -1.0f, -1.0f,
231
+ 1.0f, -1.0f,
232
+ -1.0f, 1.0f,
233
+ 1.0f, 1.0f
234
+ };
235
+
236
+ private static final float[] TEXCOORD_DATA = {
237
+ 0.0f, 1.0f,
238
+ 1.0f, 1.0f,
239
+ 0.0f, 0.0f,
240
+ 1.0f, 0.0f
241
+ };
242
+
243
+ public AlphaVideoGpuView(Context context) {
244
+ super(context);
245
+ init(context);
246
+ }
247
+
248
+ public AlphaVideoGpuView(Context context, @Nullable AttributeSet attrs) {
249
+ super(context, attrs);
250
+ init(context);
251
+ }
252
+
253
+ public AlphaVideoGpuView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
254
+ super(context, attrs, defStyleAttr);
255
+ init(context);
256
+ }
257
+
258
+ private void init(Context context) {
259
+ setSurfaceTextureListener(this);
260
+ setOpaque(false); // Critical for transparency
261
+ isDestroyed.set(false);
262
+
263
+ // Don't intercept touches
264
+ setClickable(false);
265
+ setFocusable(false);
266
+ setFocusableInTouchMode(false);
267
+
268
+ // Setup vertex buffers
269
+ vertexBuffer = ByteBuffer.allocateDirect(VERTEX_DATA.length * 4)
270
+ .order(ByteOrder.nativeOrder())
271
+ .asFloatBuffer()
272
+ .put(VERTEX_DATA);
273
+ vertexBuffer.position(0);
274
+
275
+ texCoordBuffer = ByteBuffer.allocateDirect(TEXCOORD_DATA.length * 4)
276
+ .order(ByteOrder.nativeOrder())
277
+ .asFloatBuffer()
278
+ .put(TEXCOORD_DATA);
279
+ texCoordBuffer.position(0);
280
+ }
281
+
282
+ private synchronized ExecutorService getOrCreateExecutor() {
283
+ if (executor == null || executor.isShutdown()) {
284
+ // Use a thread pool with 2 threads to allow preloading while playback runs
285
+ executor = Executors.newFixedThreadPool(2);
286
+ }
287
+ return executor;
288
+ }
289
+
290
+ private synchronized void shutdownExecutor() {
291
+ if (executor != null && !executor.isShutdown()) {
292
+ executor.shutdownNow();
293
+ executor = null;
294
+ }
295
+ }
296
+
297
+ private void notifyStateChange(String state) {
298
+ if (playerCallback != null) playerCallback.onStateChange(state);
299
+ }
300
+
301
+ private void notifyLoad() {
302
+ if (playerCallback != null) playerCallback.onLoad(frameWidth, frameHeight, totalFrames, frameRate);
303
+ }
304
+
305
+ private void notifyFrameUpdate() {
306
+ if (playerCallback != null) playerCallback.onFrameUpdate(currentFrame, totalFrames);
307
+ }
308
+
309
+ private void notifyFinished() {
310
+ if (playerCallback != null) playerCallback.onFinished();
311
+ }
312
+
313
+ private void notifyError(String error) {
314
+ notifyError(error, null);
315
+ }
316
+
317
+ private void notifyError(String error, @Nullable String code) {
318
+ Log.e(TAG, "Error: " + error);
319
+ if (playerCallback != null) playerCallback.onError(error, code);
320
+ }
321
+
322
+ // Public API
323
+
324
+ /** Receives this view's events on the main thread; null to stop. */
325
+ public void setPlayerCallback(@Nullable PlayerCallback callback) {
326
+ playerCallback = callback;
327
+ }
328
+
329
+ /**
330
+ * What to play, or null to stop: https:// or http:// (read by range as it plays, and kept as
331
+ * {@link #setStorage} says), file:// or an absolute path, asset:// (APK assets), content://,
332
+ * a res/raw resource name, or with {@link #setDataSource} any URI that data source reads.
333
+ * Set the other source properties first. Playback starts once the view's surface exists.
334
+ */
335
+ public void setSource(String source) {
336
+ // Callers (React re-renders, for one) set the same source again, and for an unchanged source the
337
+ // stop-then-start below is pure churn: it discards the decoded ring buffer
338
+ // and briefly leaves the view with no running worker. Only act when the value
339
+ // actually changed, or when nothing is currently playing (so a stalled or
340
+ // finished player can still be revived by setting the same source again).
341
+ boolean playing = isPlaybackRunning.get() && !shouldStop.get();
342
+ boolean sameReads = mHeaders.equals(mPlayingHeaders) && Objects.equals(mDataSource, mPlayingDataSource);
343
+ if (source != null && source.equals(mSource) && playing && sameReads) {
344
+ logD("setSource: unchanged and still playing, ignoring");
345
+ return;
346
+ }
347
+
348
+ // A presigned URL is re-signed every time the app lists its media, so the URL
349
+ // changes while still addressing the same object. The cache key is the stable
350
+ // identity, so when it matches what is already on screen this is a credential
351
+ // refresh rather than a new asset, and restarting would visibly interrupt a
352
+ // loop that is supposed to run until unmount.
353
+ if (source != null && playing && sameReads
354
+ && mCacheKey != null && !mCacheKey.isEmpty()
355
+ && mCacheKey.equals(mPlayingCacheKey)) {
356
+ logD("setSource: same cacheKey (" + mCacheKey + ") still playing, ignoring URL change");
357
+ mSource = source;
358
+ return;
359
+ }
360
+
361
+ logD("setSource: " + (source != null ? source.substring(0, Math.min(80, source.length())) : "null"));
362
+
363
+ stopPlayback();
364
+ mSource = source;
365
+ mSourceLoaded = false;
366
+
367
+ if (source != null && isAvailable()) {
368
+ startPlayback();
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Names what the store keeps a URL's bytes under, instead of the URL, for URLs that change
374
+ * while the file doesn't (presigned URLs). A source with the same cache key as the one playing
375
+ * keeps playing. Set it before the source.
376
+ */
377
+ public void setCacheKey(String cacheKey) {
378
+ mCacheKey = cacheKey;
379
+ logD("setCacheKey: " + cacheKey);
380
+ }
381
+
382
+ /** Sent with every request for an http(s) source, and given to a data source; null for none. */
383
+ public void setHeaders(@Nullable Map<String, String> headers) {
384
+ mHeaders = headers == null || headers.isEmpty()
385
+ ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(headers));
386
+ }
387
+
388
+ /**
389
+ * Reads the source through the data source registered under this name with the range-source
390
+ * add-on ({@code dev.mediacache.rangesource.RangeDataSources.register}), whatever its scheme;
391
+ * null for the built-in ones.
392
+ */
393
+ public void setDataSource(@Nullable String name) {
394
+ mDataSource = name == null || name.isEmpty() ? null : name;
395
+ }
396
+
397
+ /**
398
+ * Where a URL's or data source's bytes are kept between plays: {@code "disk"} (in the cache
399
+ * folder), {@code "memory"} (for the process's lifetime) or {@code "none"}. {@code "auto"}, the
400
+ * default, is disk for URLs and none for a data source, whose caching is its own. The store is
401
+ * partial: it keeps what has been played, and a clip held whole plays again offline.
402
+ */
403
+ public void setStorage(@Nullable String storage) {
404
+ switch (storage == null ? STORAGE_AUTO : storage) {
405
+ case STORAGE_DISK:
406
+ case STORAGE_MEMORY:
407
+ case STORAGE_NONE:
408
+ mStorage = storage;
409
+ break;
410
+ default:
411
+ mStorage = STORAGE_AUTO;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * The cap of this player's store, over every clip in it, least recently played out first;
417
+ * 0 or less for the default, 512 MB. Every view shares the store, under the cap set last.
418
+ */
419
+ public void setMaxStorageBytes(long maxStorageBytes) {
420
+ mMaxStorageBytes = maxStorageBytes > 0 ? maxStorageBytes : DEFAULT_MAX_STORAGE_BYTES;
421
+ }
422
+
423
+ public void setAutoPlay(boolean autoPlay) {
424
+ this.autoPlay = autoPlay;
425
+ }
426
+
427
+ public void setLoop(boolean loop) {
428
+ this.loop = loop;
429
+ }
430
+
431
+ public void setPlaybackSpeed(float speed) {
432
+ this.playbackSpeed = speed;
433
+ }
434
+
435
+ public void setEnableFrameEvents(boolean enable) {
436
+ this.enableFrameEvents = enable;
437
+ }
438
+
439
+ public void setStreamingPrefetchAhead(int frames) {
440
+ this.configStreamingPrefetchAhead = Math.max(1, Math.min(100, frames));
441
+ logD("setStreamingPrefetchAhead: " + this.configStreamingPrefetchAhead);
442
+ }
443
+
444
+ /**
445
+ * Set memory strategy for frame storage.
446
+ * - "vram" (default): frames in GPU VRAM (best for many simultaneous players)
447
+ * - "ram": Decoded frames in RAM, upload per-frame (lower VRAM usage)
448
+ * Any other value means "vram".
449
+ */
450
+ public void setMemoryStrategy(String strategy) {
451
+ this.memoryStrategy = "ram".equals(strategy) ? "ram" : "vram";
452
+ logD("setMemoryStrategy: " + this.memoryStrategy);
453
+ }
454
+
455
+ /**
456
+ * Set streaming mode for texture array playback.
457
+ * When true (default): Use streaming decompression with ring buffer (constant memory usage).
458
+ * When false: Load all frames at once.
459
+ */
460
+ public void setStream(boolean stream) {
461
+ this.streamProp = stream;
462
+ logD("setStream: " + stream);
463
+ }
464
+
465
+ /**
466
+ * Pauses or resumes playback, keeping the loaded frames (for screen focus and blur).
467
+ * Resuming plays again only when autoPlay is on.
468
+ */
469
+ public void setPaused(boolean paused) {
470
+ boolean wasPaused = isPaused.getAndSet(paused);
471
+ if (paused != wasPaused) {
472
+ logD("setPaused: " + paused);
473
+ if (paused) {
474
+ // Stop playback
475
+ isPlaying.set(false);
476
+ } else {
477
+ // Resume playback if we were playing before and source is loaded
478
+ if (mSourceLoaded && autoPlay) {
479
+ isPlaying.set(true);
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ /** Logs debug and info traces when true; warnings and errors are always logged. */
486
+ public void setVerbose(boolean verbose) {
487
+ this.verbose = verbose;
488
+ }
489
+
490
+ /** Plays from the shown frame, or from the start once a clip that doesn't loop has ended. */
491
+ public void play() {
492
+ if (mSourceLoaded && !isPlaying.getAndSet(true)) {
493
+ notifyStateChange("playing");
494
+ }
495
+ }
496
+
497
+ public void pause() {
498
+ if (isPlaying.getAndSet(false)) {
499
+ notifyStateChange("paused");
500
+ }
501
+ }
502
+
503
+ /** Pauses on the first frame. */
504
+ public void stop() {
505
+ isPlaying.set(false);
506
+ seekToFrame(0);
507
+ notifyStateChange("stopped");
508
+ }
509
+
510
+ /**
511
+ * Shows that frame (clamped to the clip) and keeps playing or stays paused. Ignored until
512
+ * onLoad. In stream mode the frames before it are decoded again, unless they are buffered.
513
+ */
514
+ public void seekToFrame(int frame) {
515
+ int frames = totalFrames;
516
+ if (!mSourceLoaded || frames <= 0) return;
517
+ int target = Math.max(0, Math.min(frame, frames - 1));
518
+ seekRequest.set(target);
519
+ if (playerCallback != null) playerCallback.onFrameUpdate(target, frames);
520
+ }
521
+
522
+ /** Seeks to the frame showing at that time, in seconds. */
523
+ public void seekToTime(double seconds) {
524
+ double frame = Math.floor(Math.max(seconds, 0) * frameRate + 1e-6);
525
+ seekToFrame((int) Math.min(frame, Integer.MAX_VALUE));
526
+ }
527
+
528
+ /** Stops playback and its threads. Detaching the view from its window does the same. */
529
+ public void destroy() {
530
+ isDestroyed.set(true);
531
+ shouldStop.set(true);
532
+ isPlaying.set(false);
533
+ shutdownExecutor();
534
+ }
535
+
536
+ private void startPlayback() {
537
+ if (mSource == null || !isAvailable() || isDestroyed.get()) {
538
+ return;
539
+ }
540
+
541
+ // Prevent concurrent playback attempts. A previous worker may still be
542
+ // unwinding after stopPlayback() raised shouldStop; in that case the restart
543
+ // is owed rather than redundant, so record it and let the exiting worker
544
+ // relaunch. Returning without doing this leaves shouldStop raised with no
545
+ // worker running, which strands the view until the surface is recreated.
546
+ if (!isPlaybackRunning.compareAndSet(false, true)) {
547
+ logD("startPlayback: worker still running, queueing restart");
548
+ pendingRestart.set(true);
549
+ return;
550
+ }
551
+
552
+ shouldStop.set(false);
553
+ mPlayingCacheKey = mCacheKey;
554
+ mPlayingHeaders = mHeaders;
555
+ mPlayingDataSource = mDataSource;
556
+
557
+ try {
558
+ getOrCreateExecutor().execute(() -> {
559
+ try {
560
+ playAnimation();
561
+ } catch (Exception e) {
562
+ Log.e(TAG, "Playback error", e);
563
+ mainHandler.post(() -> notifyError(e.getMessage()));
564
+ } finally {
565
+ cleanupGL();
566
+ mPlayingCacheKey = null;
567
+ isPlaybackRunning.set(false);
568
+ // Clear the guard first, then service any restart requested while
569
+ // this worker was shutting down. Hopping to the main thread keeps
570
+ // the view/surface reads in startPlayback() on the UI thread.
571
+ if (pendingRestart.getAndSet(false) && !isDestroyed.get()) {
572
+ mainHandler.post(this::startPlayback);
573
+ }
574
+ }
575
+ });
576
+ } catch (Exception e) {
577
+ Log.e(TAG, "Failed to start playback", e);
578
+ isPlaybackRunning.set(false);
579
+ }
580
+ }
581
+
582
+ private void stopPlayback() {
583
+ shouldStop.set(true);
584
+ isPlaying.set(false);
585
+ }
586
+
587
+ private void playAnimation() {
588
+ final String uri = mSource;
589
+ final AlphaVideoGpuSource local;
590
+ try {
591
+ local = resolveSource(uri, mPlayingCacheKey);
592
+ } catch (NoSourceModuleException e) {
593
+ mainHandler.post(() -> notifyError(e.getMessage(), ERROR_NO_SOURCE_MODULE));
594
+ return;
595
+ } catch (IOException | IllegalArgumentException e) {
596
+ if (shouldStop.get()) return; // stopped while opening
597
+ mainHandler.post(() -> notifyError("Loading " + uri + " failed: " + e.getMessage()));
598
+ return;
599
+ }
600
+ try {
601
+ if (!shouldStop.get()) playSource(local);
602
+ } finally {
603
+ AlphaVideoGpuReader reader = local.reader();
604
+ if (reader != null) reader.close();
605
+ }
606
+ }
607
+
608
+ private void playSource(AlphaVideoGpuSource local) {
609
+ // Initialize OpenGL
610
+ SurfaceTexture surfaceTexture = getSurfaceTexture();
611
+ if (surfaceTexture == null || !initGL(surfaceTexture)) {
612
+ mainHandler.post(() -> notifyError("Failed to initialize OpenGL"));
613
+ return;
614
+ }
615
+
616
+ astcSupported = detectASTCSupport();
617
+
618
+ // The header, level index and metadata only; streaming reads the level as it plays
619
+ byte[] header;
620
+ try {
621
+ header = readHeader(local);
622
+ } catch (IOException e) {
623
+ if (shouldStop.get()) return;
624
+ mainHandler.post(() -> notifyError("Failed to read " + local + ": " + e.getMessage()));
625
+ return;
626
+ }
627
+
628
+ String metadataError = applyMetadata(Ktx2Metadata.read(header));
629
+ if (metadataError != null) {
630
+ mainHandler.post(() -> notifyError(metadataError));
631
+ return;
632
+ }
633
+
634
+ if (streamProp) {
635
+ // Check supercompressionScheme from KTX2 header
636
+ int supercompressionScheme = getSupercompressionScheme(header);
637
+ logI("Streaming mode enabled (stream=true), supercompression=" + supercompressionScheme);
638
+
639
+ if (supercompressionScheme == 2) {
640
+ playInternalZstdStreamingAnimation(local, header);
641
+ return;
642
+ } else if (supercompressionScheme == 0) {
643
+ // Uncompressed - use mmap streaming
644
+ playUncompressedStreamingAnimation(local);
645
+ return;
646
+ } else {
647
+ Log.w(TAG, "Unsupported supercompression for streaming: " + supercompressionScheme + ", falling back to non-stream");
648
+ }
649
+ }
650
+
651
+ // Load textures (non-streaming path)
652
+ try {
653
+ loadTextureArray(local);
654
+ } catch (Exception e) {
655
+ if (shouldStop.get()) return;
656
+ mainHandler.post(() -> notifyError("Failed to load textures: " + e.getMessage()));
657
+ return;
658
+ }
659
+
660
+ // Initialize shaders
661
+ if (!initShaders()) {
662
+ mainHandler.post(() -> notifyError("Failed to initialize shaders"));
663
+ return;
664
+ }
665
+
666
+ announceLoaded();
667
+
668
+ logD("Starting render loop");
669
+
670
+ FrameClock clock = new FrameClock(frameIntervalNanos());
671
+ currentFrame = 0;
672
+ boolean frameOwed = true;
673
+ boolean wasPlaying = false;
674
+ try {
675
+ while (!shouldStop.get() && !isDestroyed.get()) {
676
+ int seek = takeSeek();
677
+ if (seek >= 0) {
678
+ currentFrame = seek;
679
+ frameOwed = true;
680
+ wasPlaying = false;
681
+ }
682
+
683
+ if (!isPlaying.get()) {
684
+ if (frameOwed) {
685
+ renderFrame();
686
+ frameOwed = false;
687
+ }
688
+ wasPlaying = false;
689
+ Thread.sleep(16);
690
+ continue;
691
+ }
692
+ if (!wasPlaying) {
693
+ clock.restart();
694
+ wasPlaying = true;
695
+ }
696
+
697
+ renderFrame();
698
+ frameOwed = false;
699
+ if (enableFrameEvents) {
700
+ mainHandler.post(() -> notifyFrameUpdate());
701
+ }
702
+ clock.awaitNext();
703
+
704
+ if (currentFrame + 1 < totalFrames) {
705
+ currentFrame++;
706
+ } else if (loop) {
707
+ currentFrame = 0;
708
+ } else {
709
+ finish();
710
+ }
711
+ }
712
+ } catch (InterruptedException e) {
713
+ Thread.currentThread().interrupt();
714
+ }
715
+ }
716
+
717
+ /** Reports the load, and starts playing when autoPlay is on and the view isn't paused. */
718
+ private void announceLoaded() {
719
+ ended = false;
720
+ seekRequest.set(-1);
721
+ mSourceLoaded = true;
722
+ mainHandler.post(() -> {
723
+ notifyLoad();
724
+ notifyStateChange("ready");
725
+ });
726
+ if (autoPlay && !isPaused.get()) {
727
+ isPlaying.set(true);
728
+ mainHandler.post(() -> notifyStateChange("playing"));
729
+ }
730
+ }
731
+
732
+ private long frameIntervalNanos() {
733
+ return (long) (1_000_000_000.0 / (frameRate * playbackSpeed));
734
+ }
735
+
736
+ /**
737
+ * The frame a render loop should show next, or -1 to carry on: the requested seek, or
738
+ * frame 0 when a clip that ended is played again.
739
+ */
740
+ private int takeSeek() {
741
+ int seek = seekRequest.getAndSet(-1);
742
+ if (seek < 0 && ended && isPlaying.get()) seek = 0;
743
+ if (seek >= 0) ended = false;
744
+ return seek;
745
+ }
746
+
747
+ /** Holds the last frame of a clip that doesn't loop. */
748
+ private void finish() {
749
+ ended = true;
750
+ isPlaying.set(false);
751
+ mainHandler.post(() -> {
752
+ notifyFinished();
753
+ notifyStateChange("stopped");
754
+ });
755
+ }
756
+
757
+ /** Paces frames from the moment playback starts, resumes or seeks. */
758
+ private static final class FrameClock {
759
+ private final long intervalNs;
760
+ private long dueNs;
761
+
762
+ FrameClock(long intervalNs) {
763
+ this.intervalNs = intervalNs;
764
+ }
765
+
766
+ /** The current frame is due now. */
767
+ void restart() {
768
+ dueNs = System.nanoTime();
769
+ }
770
+
771
+ /** Sleeps until the next frame is due. */
772
+ void awaitNext() throws InterruptedException {
773
+ dueNs += intervalNs;
774
+ long sleepNs = dueNs - System.nanoTime();
775
+ if (sleepNs > 1_000_000) {
776
+ Thread.sleep(sleepNs / 1_000_000, (int) (sleepNs % 1_000_000));
777
+ } else if (sleepNs < -intervalNs) {
778
+ // Too far behind to catch up (a slow decode), so pace from now
779
+ dueNs = System.nanoTime();
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Drops the next {@code bytes} of decompressed data: first what the accumulator holds, then
786
+ * the rest through {@code bytesToSkip} as the stream is read.
787
+ */
788
+ private static void skipDecodedBytes(ByteBuffer accumulator, long[] bytesToSkip, long bytes) {
789
+ int held = accumulator.position();
790
+ if (held > bytes) {
791
+ accumulator.flip();
792
+ accumulator.position((int) bytes);
793
+ accumulator.compact();
794
+ } else {
795
+ accumulator.clear();
796
+ bytesToSkip[0] += bytes - held;
797
+ }
798
+ }
799
+
800
+ /** Forgets every decoded frame after a seek, returning the CPU buffers to the pool. */
801
+ private static void dropStreamedFrames(java.util.Map<Integer, byte[]> frameCache,
802
+ @Nullable java.util.Queue<byte[]> bufferPool,
803
+ int[] ringSlots, int bytesPerLayer) {
804
+ if (bufferPool != null) {
805
+ for (byte[] buffer : frameCache.values()) {
806
+ if (buffer.length == bytesPerLayer) bufferPool.offer(buffer);
807
+ }
808
+ }
809
+ frameCache.clear();
810
+ Arrays.fill(ringSlots, -1);
811
+ }
812
+
813
+ /**
814
+ * The file's first bytes, through the end of its key/value data: the header, the level index
815
+ * and the metadata. 16 KB, or more when the key/value data runs past it.
816
+ */
817
+ private byte[] readHeader(AlphaVideoGpuSource local) throws IOException {
818
+ final int guess = 16 * 1024;
819
+ byte[] head = local.readHead(getContext(), guess);
820
+ if (head.length < 80) throw new IOException("Too short for a KTX2 file (" + head.length + " bytes)");
821
+ ByteBuffer header = ByteBuffer.wrap(head).order(ByteOrder.LITTLE_ENDIAN);
822
+ long kvdEnd = (header.getInt(56) & 0xFFFFFFFFL) + (header.getInt(60) & 0xFFFFFFFFL);
823
+ if (kvdEnd <= head.length || head.length < guess) return head;
824
+ if (kvdEnd > 16L * 1024 * 1024) throw new IOException("Key/value data of " + kvdEnd + " bytes");
825
+ return local.readHead(getContext(), (int) kvdEnd);
826
+ }
827
+
828
+ /**
829
+ * The seek table at the end of the level, read from the level's last bytes; null when the
830
+ * level is one stream.
831
+ */
832
+ @Nullable
833
+ private ZstdSeekTable readSeekTable(AlphaVideoGpuSource local, long levelOffset, long levelLength,
834
+ long decompressedLength) throws IOException {
835
+ if (levelLength <= 0) return null;
836
+ long levelEnd = levelOffset + levelLength;
837
+ int count = (int) Math.min(levelLength, ZstdSeekTable.TAIL_GUESS);
838
+ byte[] tail = local.readRange(getContext(), levelEnd - count, count);
839
+ long needed = ZstdSeekTable.tailBytesNeeded(tail);
840
+ if (needed > tail.length && needed <= levelLength && needed <= Integer.MAX_VALUE) {
841
+ tail = local.readRange(getContext(), levelEnd - needed, (int) needed);
842
+ }
843
+ return ZstdSeekTable.parseTail(tail, levelLength, decompressedLength);
844
+ }
845
+
846
+ /**
847
+ * Play uncompressed streaming animation from .ktx2 file.
848
+ * Uses memory-mapped file for true zero-copy frame uploads.
849
+ * This is the fastest streaming mode - no decompression overhead.
850
+ * Does not require pre-loading the file into memory - reads header from mmap.
851
+ */
852
+ private void playUncompressedStreamingAnimation(AlphaVideoGpuSource local) {
853
+ logI("Starting uncompressed streaming animation: " + local);
854
+
855
+ // Memory-map the file for zero-copy reads - do this FIRST to avoid loading file into RAM.
856
+ // A URL or data source is read whole into memory instead: uncompressed frames are as
857
+ // large as decoded ones, so such files are for local playback, and zstd ones stream.
858
+ final ByteBuffer mmapBuffer;
859
+ try {
860
+ mmapBuffer = local.reader() != null ? local.readAllDirect(getContext()) : local.map(getContext());
861
+ } catch (Exception e) {
862
+ if (shouldStop.get()) return;
863
+ Log.e(TAG, "Failed to read " + local + ": " + e.getMessage());
864
+ mainHandler.post(() -> notifyError("Failed to stream " + local + ": " + e.getMessage()));
865
+ return;
866
+ }
867
+ mmapBuffer.order(ByteOrder.LITTLE_ENDIAN);
868
+ logI("Streaming from " + mmapBuffer.capacity() + " bytes " + (local.reader() != null ? "in memory" : "mapped"));
869
+
870
+ // Parse KTX2 header directly from mmap buffer (no RAM copy needed)
871
+ int vkFormat = mmapBuffer.getInt(12);
872
+ int pixelWidth = mmapBuffer.getInt(20);
873
+ int pixelHeight = mmapBuffer.getInt(24);
874
+ int layerCount = mmapBuffer.getInt(32);
875
+ if (layerCount == 0) layerCount = 1;
876
+ long level0ByteOffset = mmapBuffer.getLong(80);
877
+
878
+ // Configure ASTC format from vkFormat
879
+ configureASTCFromVkFormat(vkFormat);
880
+
881
+ // Calculate bytes per frame using dynamic ASTC block size
882
+ int bytesPerLayer = astcBytesPerLayer(pixelWidth, pixelHeight);
883
+
884
+ logI("Uncompressed streaming: " + pixelWidth + "x" + pixelHeight + ", " +
885
+ layerCount + " layers, " + bytesPerLayer + " bytes/layer (ASTC " + astcBlockWidth + "x" + astcBlockHeight + "), data offset=" + level0ByteOffset);
886
+
887
+ // Frames are read from the mapping as they're shown, nothing ahead, so a small ring does
888
+ // for either memory strategy
889
+ boolean useVramStrategy = !"ram".equals(memoryStrategy);
890
+ boolean useAstc = astcSupported;
891
+ int gpuCacheSize = Math.min(RAM_RING_SIZE, layerCount);
892
+
893
+ logI("Uncompressed streaming params: strategy=" + memoryStrategy +
894
+ ", gpuCache=" + gpuCacheSize + ", vramMode=" + useVramStrategy);
895
+
896
+ // Create GPU texture array
897
+ textureArrayId = AlphaVideoGpuNative.nativeCreateTextureArray(pixelWidth, pixelHeight, gpuCacheSize, useAstc, astcGlFormat);
898
+ if (textureArrayId == 0) {
899
+ mainHandler.post(() -> notifyError("Failed to create texture array for uncompressed streaming"));
900
+ return;
901
+ }
902
+
903
+ logI("Created uncompressed streaming texture array: ID=" + textureArrayId +
904
+ ", " + pixelWidth + "x" + pixelHeight + ", " + gpuCacheSize + " GPU slots");
905
+
906
+ // Initialize ring buffer tracking
907
+ streamingRingBufferMode = true;
908
+ streamingRingSize = gpuCacheSize;
909
+ int[] ringSlots = new int[gpuCacheSize];
910
+ Arrays.fill(ringSlots, -1);
911
+
912
+ // Initialize PBOs for async uploads (only for ASTC compressed format)
913
+ if (useAstc) {
914
+ pbosInitialized = AlphaVideoGpuNative.nativeInitPBOs(textureArrayId, bytesPerLayer);
915
+ if (pbosInitialized) {
916
+ logI("PBOs initialized for uncompressed streaming: " + bytesPerLayer + " bytes per buffer");
917
+ }
918
+ }
919
+
920
+ // Initialize shaders
921
+ if (!initShaders()) {
922
+ mainHandler.post(() -> notifyError("Failed to initialize shaders"));
923
+ return;
924
+ }
925
+
926
+ totalFrames = layerCount;
927
+ announceLoaded();
928
+
929
+ // Frame timing - use metadata frameRate (already parsed)
930
+ long frameIntervalNs = frameIntervalNanos();
931
+ logI("Uncompressed streaming timing: frameRate=" + frameRate + ", playbackSpeed=" + playbackSpeed +
932
+ ", frameIntervalNs=" + frameIntervalNs + " (" + (frameIntervalNs / 1_000_000.0) + "ms)");
933
+
934
+ // Capture finals for lambda
935
+ final int finalWidth = pixelWidth;
936
+ final int finalHeight = pixelHeight;
937
+ final int finalBytesPerLayer = bytesPerLayer;
938
+ final int finalLayerCount = layerCount;
939
+ final int finalGpuCacheSize = gpuCacheSize;
940
+ final long finalDataOffset = level0ByteOffset;
941
+ final boolean finalUseAstc = useAstc;
942
+
943
+ // Main playback loop - simple and fast for uncompressed data. Any frame can be read
944
+ // straight from the mapping, so a seek only moves the playhead.
945
+ int debugFrameCounter = 0;
946
+ FrameClock clock = new FrameClock(frameIntervalNs);
947
+ currentFrame = 0;
948
+ boolean frameOwed = true;
949
+ boolean wasPlaying = false;
950
+ try {
951
+ while (!shouldStop.get() && !isDestroyed.get()) {
952
+ int seek = takeSeek();
953
+ if (seek >= 0) {
954
+ currentFrame = seek;
955
+ frameOwed = true;
956
+ wasPlaying = false;
957
+ }
958
+
959
+ boolean playing = isPlaying.get();
960
+ if (!playing && !frameOwed) {
961
+ wasPlaying = false;
962
+ Thread.sleep(16);
963
+ continue;
964
+ }
965
+
966
+ // Calculate GPU slot for this frame
967
+ int gpuSlot = currentFrame % finalGpuCacheSize;
968
+
969
+ // Check if frame needs to be uploaded
970
+ if (ringSlots[gpuSlot] != currentFrame) {
971
+ // Calculate offset in mmap buffer for this frame
972
+ long frameOffset = finalDataOffset + (long)currentFrame * finalBytesPerLayer;
973
+
974
+ // Upload directly from mmap buffer (true zero-copy!). The limit goes back
975
+ // to the end first, since a frame after the last one read lies past it.
976
+ mmapBuffer.clear();
977
+ mmapBuffer.position((int)frameOffset);
978
+ mmapBuffer.limit((int)frameOffset + finalBytesPerLayer);
979
+ ByteBuffer frameSlice = mmapBuffer.slice();
980
+ frameSlice.order(ByteOrder.nativeOrder());
981
+
982
+ // Use PBO for async upload when available
983
+ boolean uploaded = pbosInitialized ?
984
+ AlphaVideoGpuNative.nativeUploadTextureArrayLayerPBO(
985
+ textureArrayId, gpuSlot, finalWidth, finalHeight,
986
+ frameSlice, finalBytesPerLayer, finalUseAstc,
987
+ astcGlFormat, astcBlockWidth, astcBlockHeight) :
988
+ AlphaVideoGpuNative.nativeUploadTextureArrayLayerFromBuffer(
989
+ textureArrayId, gpuSlot, finalWidth, finalHeight,
990
+ frameSlice, finalBytesPerLayer, finalUseAstc,
991
+ astcGlFormat, astcBlockWidth, astcBlockHeight);
992
+
993
+ if (uploaded) {
994
+ ringSlots[gpuSlot] = currentFrame;
995
+ }
996
+ }
997
+
998
+ // Render (includes eglSwapBuffers)
999
+ renderTextureArrayFrame();
1000
+ frameOwed = false;
1001
+ if (!playing) {
1002
+ wasPlaying = false;
1003
+ continue;
1004
+ }
1005
+ if (!wasPlaying) {
1006
+ clock.restart();
1007
+ wasPlaying = true;
1008
+ }
1009
+ if (enableFrameEvents) {
1010
+ mainHandler.post(() -> notifyFrameUpdate());
1011
+ }
1012
+
1013
+ // Debug logging every 30 frames
1014
+ if (debugFrameCounter % 30 == 0) {
1015
+ logD("Uncompressed frame " + currentFrame + "/" + finalLayerCount +
1016
+ ", gpuSlot=" + (currentFrame % finalGpuCacheSize) +
1017
+ ", ringSize=" + streamingRingSize);
1018
+ }
1019
+ debugFrameCounter++;
1020
+
1021
+ // Frame timing - wait until next frame time
1022
+ clock.awaitNext();
1023
+
1024
+ if (currentFrame + 1 < finalLayerCount) {
1025
+ currentFrame++;
1026
+ } else if (loop) {
1027
+ currentFrame = 0;
1028
+ logI("Uncompressed streaming loop restart");
1029
+ } else {
1030
+ finish();
1031
+ }
1032
+ }
1033
+ } catch (InterruptedException e) {
1034
+ Thread.currentThread().interrupt();
1035
+ }
1036
+
1037
+ logI("Uncompressed streaming animation ended");
1038
+ }
1039
+
1040
+ /**
1041
+ * Play streaming animation from KTX2 file with internal zstd compression (supercompressionScheme=2).
1042
+ * Only the level0 data is compressed, so the header is read directly and the layers are
1043
+ * decompressed as they play.
1044
+ *
1045
+ * A URL or data source is read as it decodes, without waiting: frames whose bytes haven't
1046
+ * arrived are simply not ready yet, and the shown frame stays.
1047
+ *
1048
+ * @param local The .ktx2 file
1049
+ * @param headerData The file's header, through its key/value data (level0 offset and size)
1050
+ */
1051
+ private void playInternalZstdStreamingAnimation(AlphaVideoGpuSource local, byte[] headerData) {
1052
+ logI("Starting internal zstd streaming animation: " + local);
1053
+
1054
+ // Parse KTX2 header to get level0 info
1055
+ ByteBuffer header = ByteBuffer.wrap(headerData).order(ByteOrder.LITTLE_ENDIAN);
1056
+ int vkFormat = header.getInt(12);
1057
+ int pixelWidth = header.getInt(20);
1058
+ int pixelHeight = header.getInt(24);
1059
+ int layerCount = header.getInt(32);
1060
+ if (layerCount == 0) layerCount = 1;
1061
+ long level0Offset = header.getLong(80);
1062
+ long level0Length = header.getLong(88); // Compressed size
1063
+ long level0UncompressedLength = header.getLong(96);
1064
+
1065
+ // Configure ASTC format from vkFormat
1066
+ configureASTCFromVkFormat(vkFormat);
1067
+
1068
+ logI("Internal zstd: " + pixelWidth + "x" + pixelHeight + ", " + layerCount + " layers, " +
1069
+ "compressed=" + level0Length + ", uncompressed=" + level0UncompressedLength + ", vkFormat=" + vkFormat);
1070
+
1071
+ // Calculate bytes per layer using dynamic ASTC block size
1072
+ int bytesPerLayer = astcBytesPerLayer(pixelWidth, pixelHeight);
1073
+
1074
+ // Verify uncompressed size matches expected
1075
+ long expectedUncompressed = (long)bytesPerLayer * layerCount;
1076
+ if (level0UncompressedLength != expectedUncompressed) {
1077
+ Log.w(TAG, "Uncompressed size mismatch: header=" + level0UncompressedLength + ", expected=" + expectedUncompressed);
1078
+ }
1079
+ final ZstdSeekTable seekTable;
1080
+ try {
1081
+ seekTable = readSeekTable(local, level0Offset, level0Length, level0UncompressedLength);
1082
+ } catch (IOException e) {
1083
+ if (shouldStop.get()) return;
1084
+ mainHandler.post(() -> notifyError("Failed to read " + local + ": " + e.getMessage()));
1085
+ return;
1086
+ }
1087
+ logI(seekTable != null ? "Seek table: " + seekTable.size() + " seek points" : "No seek table: seeks decode from the start");
1088
+
1089
+ // Initialize internal zstd streaming decompression over a memory-mapped range
1090
+ final long[] streamId = {0};
1091
+ try {
1092
+ streamId[0] = openInternalZstdStream(local, level0Offset, level0Length);
1093
+ } catch (IOException e) {
1094
+ mainHandler.post(() -> notifyError("Failed to stream " + local + ": " + e.getMessage()));
1095
+ return;
1096
+ }
1097
+ if (streamId[0] == 0) {
1098
+ mainHandler.post(() -> notifyError("Failed to initialize internal zstd streaming"));
1099
+ return;
1100
+ }
1101
+
1102
+ totalFrames = layerCount;
1103
+ frameWidth = pixelWidth;
1104
+ frameHeight = pixelHeight;
1105
+
1106
+ boolean useAstc = astcSupported;
1107
+
1108
+ // Determine memory strategy
1109
+ final boolean useVramStrategy = !"ram".equals(memoryStrategy);
1110
+ final int PREFETCH_AHEAD = configStreamingPrefetchAhead;
1111
+ // The shown frame, the ones decoded after it, and the previous frame, which the GPU may
1112
+ // still be drawing
1113
+ final int framesKept = PREFETCH_AHEAD + 2;
1114
+ final int GPU_CACHE_SIZE = useVramStrategy ? framesKept : RAM_RING_SIZE;
1115
+ final int CPU_CACHE_SIZE = useVramStrategy ? 5 : framesKept;
1116
+
1117
+ logI("Internal zstd streaming params: strategy=" + memoryStrategy +
1118
+ ", gpuCache=" + GPU_CACHE_SIZE + ", cpuCache=" + CPU_CACHE_SIZE +
1119
+ ", prefetch=" + PREFETCH_AHEAD);
1120
+
1121
+ // Create texture array
1122
+ int textureLayerCount = Math.min(GPU_CACHE_SIZE, layerCount);
1123
+ textureArrayId = AlphaVideoGpuNative.nativeCreateTextureArray(pixelWidth, pixelHeight, textureLayerCount, useAstc, astcGlFormat);
1124
+ if (textureArrayId == 0) {
1125
+ mainHandler.post(() -> notifyError("Failed to create texture array for internal zstd streaming"));
1126
+ AlphaVideoGpuNative.nativeStreamingDecompressEnd(streamId[0]);
1127
+ return;
1128
+ }
1129
+ textureArrayLayerCount = textureLayerCount;
1130
+
1131
+ logI("Created internal zstd streaming texture array: ID=" + textureArrayId +
1132
+ ", " + pixelWidth + "x" + pixelHeight + ", " + textureLayerCount + " GPU slots");
1133
+
1134
+ streamingRingBufferMode = true;
1135
+ streamingRingSize = textureLayerCount;
1136
+
1137
+ // Initialize PBOs for async uploads (only for ASTC compressed format)
1138
+ if (useAstc) {
1139
+ pbosInitialized = AlphaVideoGpuNative.nativeInitPBOs(textureArrayId, bytesPerLayer);
1140
+ if (pbosInitialized) {
1141
+ logI("PBOs initialized for internal zstd streaming: " + bytesPerLayer + " bytes per buffer");
1142
+ }
1143
+ }
1144
+
1145
+ // Initialize shaders
1146
+ if (!initShaders()) {
1147
+ mainHandler.post(() -> notifyError("Failed to initialize shaders"));
1148
+ AlphaVideoGpuNative.nativeStreamingDecompressEnd(streamId[0]);
1149
+ return;
1150
+ }
1151
+
1152
+ // Track ring buffer slots
1153
+ int[] ringSlots = new int[textureLayerCount];
1154
+ Arrays.fill(ringSlots, -1);
1155
+
1156
+ // Decompression state - NO header to skip since we start at level0 data directly
1157
+ final int[] decodePos = {0};
1158
+ final boolean[] decompressEnded = {false};
1159
+ // The last decode stopped for bytes still on their way (a URL or data source)
1160
+ final boolean[] inputPending = {false};
1161
+ // Why the stream failed (corrupt data, or a read that failed after retries), else null
1162
+ final String[] streamError = {null};
1163
+ // The frames before a seek target
1164
+ final long[] bytesToSkip = {0};
1165
+
1166
+ // Buffer for decompression
1167
+ final int bufferSize = 64 * 1024;
1168
+ final ByteBuffer decompressBuffer = ByteBuffer.allocateDirect(bufferSize);
1169
+
1170
+ // Frame accumulator and cache
1171
+ final ByteBuffer frameAccumulatorBuffer;
1172
+ final java.util.Queue<byte[]> bufferPool;
1173
+ final java.util.Map<Integer, byte[]> frameCache;
1174
+
1175
+ if (useVramStrategy) {
1176
+ frameAccumulatorBuffer = ByteBuffer.allocateDirect(bytesPerLayer + bufferSize);
1177
+ bufferPool = null;
1178
+ frameCache = new java.util.concurrent.ConcurrentHashMap<>();
1179
+ } else {
1180
+ // RAM mode: DirectByteBuffer for accumulation (zero-copy decompression),
1181
+ // byte[] pool for CPU cache storage
1182
+ frameAccumulatorBuffer = ByteBuffer.allocateDirect(bytesPerLayer + bufferSize);
1183
+ final int POOL_SIZE = CPU_CACHE_SIZE;
1184
+ bufferPool = new java.util.concurrent.ConcurrentLinkedQueue<>();
1185
+ for (int i = 0; i < POOL_SIZE; i++) {
1186
+ bufferPool.offer(new byte[bytesPerLayer]);
1187
+ }
1188
+ frameCache = new java.util.concurrent.ConcurrentHashMap<>();
1189
+ }
1190
+ // Both modes now use DirectByteBuffer for accumulation
1191
+
1192
+ final int finalWidth = pixelWidth;
1193
+ final int finalHeight = pixelHeight;
1194
+ final int finalBytesPerLayer = bytesPerLayer;
1195
+ final int finalLayerCount = layerCount;
1196
+ final boolean finalUseAstc = useAstc;
1197
+ final int finalGpuCacheSize = textureLayerCount;
1198
+ final long finalLevel0Offset = level0Offset;
1199
+ final long finalLevel0Length = level0Length;
1200
+
1201
+ announceLoaded();
1202
+
1203
+ // Track loop iterations for wrap-around prefetching
1204
+ final int[] decoderLoopIteration = {0};
1205
+ final int vPosModulus = 2 * totalFrames;
1206
+
1207
+ // Helper: Decode frames into cache
1208
+ java.util.function.IntConsumer decodeFrames = (virtualPlaybackPos) -> {
1209
+ int currentVirtualDecodePos = (decoderLoopIteration[0] * totalFrames + decodePos[0]) % vPosModulus;
1210
+ int framesToDecode = (currentVirtualDecodePos - virtualPlaybackPos + vPosModulus) % vPosModulus;
1211
+ // framesToDecode counts the shown frame
1212
+ int targetFramesAhead = PREFETCH_AHEAD + 1;
1213
+ // A long catch-up to a seek goes on over several calls, so new seeks and stops get in
1214
+ long skipDeadline = System.nanoTime() + SKIP_SLICE_NANOS;
1215
+ inputPending[0] = false;
1216
+ if (streamError[0] != null) return;
1217
+
1218
+ while (framesToDecode < targetFramesAhead) {
1219
+ // Once decompression has ended and the last frames are out of the accumulator (a
1220
+ // 64 KB chunk can hold several small frames), restart for the next loop
1221
+ boolean framesLeft = frameAccumulatorBuffer.position() >= finalBytesPerLayer;
1222
+ if (decompressEnded[0] && !framesLeft) {
1223
+ if (!loop) break;
1224
+ // Restart decompression for next loop
1225
+ decoderLoopIteration[0]++;
1226
+ decodePos[0] = 0;
1227
+ frameAccumulatorBuffer.clear(); // Both modes use DirectByteBuffer now
1228
+ decompressEnded[0] = !restartInternalZstdStream(streamId, local, finalLevel0Offset, finalLevel0Length, 0);
1229
+ if (decompressEnded[0]) break;
1230
+ logI("Internal zstd prefetch wrap-around: restarting for loop " + decoderLoopIteration[0]);
1231
+ continue;
1232
+ }
1233
+
1234
+ while (bytesToSkip[0] > 0 && !decompressEnded[0]) {
1235
+ if (System.nanoTime() > skipDeadline) return;
1236
+ decompressBuffer.clear();
1237
+ long result = AlphaVideoGpuNative.nativeStreamingDecompressContinueToBuffer(streamId[0], decompressBuffer, bufferSize);
1238
+ if (result == -1) { streamError[0] = streamFailure(local, streamId[0]); return; }
1239
+ int status = (int)(result >> 32);
1240
+ int bytesWritten = (int)(result & 0xFFFFFFFFL);
1241
+ if (status == AlphaVideoGpuNative.STREAM_PENDING) { inputPending[0] = true; return; }
1242
+ if (bytesWritten >= bytesToSkip[0]) {
1243
+ decompressBuffer.position((int) bytesToSkip[0]);
1244
+ decompressBuffer.limit(bytesWritten);
1245
+ frameAccumulatorBuffer.put(decompressBuffer);
1246
+ bytesToSkip[0] = 0;
1247
+ } else {
1248
+ bytesToSkip[0] -= bytesWritten;
1249
+ }
1250
+ if (status == AlphaVideoGpuNative.STREAM_DONE) { decompressEnded[0] = true; break; }
1251
+ }
1252
+ if (decompressEnded[0] && frameAccumulatorBuffer.position() < finalBytesPerLayer) continue;
1253
+
1254
+ int accumulatorSize = frameAccumulatorBuffer.position();
1255
+
1256
+ // Decompress until we have a full frame
1257
+ while (accumulatorSize < finalBytesPerLayer && !decompressEnded[0]) {
1258
+ decompressBuffer.clear();
1259
+ long result = AlphaVideoGpuNative.nativeStreamingDecompressContinueToBuffer(streamId[0], decompressBuffer, bufferSize);
1260
+ if (result == -1) { streamError[0] = streamFailure(local, streamId[0]); return; }
1261
+ int status = (int)(result >> 32);
1262
+ int bytesWritten = (int)(result & 0xFFFFFFFFL);
1263
+ if (status == AlphaVideoGpuNative.STREAM_PENDING) { inputPending[0] = true; return; }
1264
+ if (bytesWritten > 0) {
1265
+ decompressBuffer.limit(bytesWritten);
1266
+ decompressBuffer.position(0);
1267
+ // Zero-copy: transfer directly between ByteBuffers (both modes)
1268
+ frameAccumulatorBuffer.put(decompressBuffer);
1269
+ accumulatorSize += bytesWritten;
1270
+ }
1271
+ if (status == AlphaVideoGpuNative.STREAM_DONE) { decompressEnded[0] = true; break; }
1272
+ }
1273
+
1274
+ if (accumulatorSize >= finalBytesPerLayer) {
1275
+ int virtualPos = (decoderLoopIteration[0] * totalFrames + decodePos[0]) % vPosModulus;
1276
+ int actualFrame = decodePos[0];
1277
+ decodePos[0]++;
1278
+
1279
+ if (useVramStrategy) {
1280
+ int gpuSlot = actualFrame % finalGpuCacheSize;
1281
+ int slotCurrentVPos = ringSlots[gpuSlot];
1282
+ int slotAge = (virtualPlaybackPos - slotCurrentVPos + vPosModulus) % vPosModulus;
1283
+ boolean slotIsOld = slotAge > 0 && slotAge < vPosModulus / 2;
1284
+ boolean canOverwrite = (slotCurrentVPos == -1) || (slotCurrentVPos == virtualPos) || slotIsOld;
1285
+
1286
+ if (canOverwrite && ringSlots[gpuSlot] != virtualPos) {
1287
+ frameAccumulatorBuffer.flip();
1288
+ // Use PBO for async upload when available
1289
+ boolean uploaded = pbosInitialized ?
1290
+ AlphaVideoGpuNative.nativeUploadTextureArrayLayerPBO(
1291
+ textureArrayId, gpuSlot, finalWidth, finalHeight,
1292
+ frameAccumulatorBuffer, finalBytesPerLayer, finalUseAstc,
1293
+ astcGlFormat, astcBlockWidth, astcBlockHeight) :
1294
+ AlphaVideoGpuNative.nativeUploadTextureArrayLayerFromBuffer(
1295
+ textureArrayId, gpuSlot, finalWidth, finalHeight,
1296
+ frameAccumulatorBuffer, finalBytesPerLayer, finalUseAstc,
1297
+ astcGlFormat, astcBlockWidth, astcBlockHeight);
1298
+ if (uploaded) {
1299
+ ringSlots[gpuSlot] = virtualPos;
1300
+ }
1301
+ if (frameAccumulatorBuffer.remaining() > finalBytesPerLayer) {
1302
+ frameAccumulatorBuffer.position(finalBytesPerLayer);
1303
+ frameAccumulatorBuffer.compact();
1304
+ } else {
1305
+ frameAccumulatorBuffer.clear();
1306
+ }
1307
+ } else {
1308
+ byte[] tempData = new byte[finalBytesPerLayer];
1309
+ frameAccumulatorBuffer.flip();
1310
+ frameAccumulatorBuffer.get(tempData, 0, finalBytesPerLayer);
1311
+ frameCache.put(virtualPos, tempData);
1312
+ if (frameAccumulatorBuffer.remaining() > 0) {
1313
+ frameAccumulatorBuffer.compact();
1314
+ } else {
1315
+ frameAccumulatorBuffer.clear();
1316
+ }
1317
+ }
1318
+ } else {
1319
+ // RAM mode: Copy from DirectByteBuffer to pooled byte[] for CPU cache
1320
+ byte[] frameData = bufferPool.poll();
1321
+ if (frameData == null) {
1322
+ frameData = new byte[finalBytesPerLayer];
1323
+ }
1324
+ // Single copy: DirectByteBuffer -> pooled byte[]
1325
+ frameAccumulatorBuffer.flip();
1326
+ frameAccumulatorBuffer.get(frameData, 0, finalBytesPerLayer);
1327
+ frameCache.put(virtualPos, frameData);
1328
+ // Keep remaining data in accumulator
1329
+ if (frameAccumulatorBuffer.remaining() > 0) {
1330
+ frameAccumulatorBuffer.compact();
1331
+ } else {
1332
+ frameAccumulatorBuffer.clear();
1333
+ }
1334
+ }
1335
+
1336
+ int newDecodePos = (decoderLoopIteration[0] * totalFrames + decodePos[0]) % vPosModulus;
1337
+ framesToDecode = (newDecodePos - virtualPlaybackPos + vPosModulus) % vPosModulus;
1338
+ } else if (!decompressEnded[0]) {
1339
+ break;
1340
+ }
1341
+ }
1342
+ };
1343
+
1344
+ // Helper: Evict old frames
1345
+ java.util.function.IntConsumer evictOldFrames = (virtualPlaybackPos) -> {
1346
+ java.util.List<Integer> toRemove = new java.util.ArrayList<>();
1347
+ if (useVramStrategy) {
1348
+ for (Integer virtualPos : frameCache.keySet()) {
1349
+ int age = (virtualPlaybackPos - virtualPos + vPosModulus) % vPosModulus;
1350
+ if (age > 0 && age < vPosModulus / 2) {
1351
+ toRemove.add(virtualPos);
1352
+ }
1353
+ }
1354
+ } else {
1355
+ for (Integer virtualPos : frameCache.keySet()) {
1356
+ int distance = (virtualPos - virtualPlaybackPos + vPosModulus) % vPosModulus;
1357
+ boolean isBehind = distance > vPosModulus - 2;
1358
+ boolean isAhead = distance <= CPU_CACHE_SIZE;
1359
+ if (!isBehind && !isAhead) {
1360
+ toRemove.add(virtualPos);
1361
+ }
1362
+ }
1363
+ }
1364
+ for (Integer virtualPos : toRemove) {
1365
+ byte[] buffer = frameCache.remove(virtualPos);
1366
+ if (buffer != null && bufferPool != null && buffer.length == finalBytesPerLayer) {
1367
+ bufferPool.offer(buffer);
1368
+ }
1369
+ }
1370
+ };
1371
+
1372
+ // Helper: Ensure frame in GPU
1373
+ java.util.function.IntConsumer ensureFrameInGpu = (virtualPos) -> {
1374
+ int actualFrame = virtualPos % totalFrames;
1375
+ int gpuSlot = actualFrame % finalGpuCacheSize;
1376
+ if (ringSlots[gpuSlot] == virtualPos) return;
1377
+ byte[] frameData = frameCache.get(virtualPos);
1378
+ if (frameData == null) return;
1379
+ boolean uploaded = AlphaVideoGpuNative.nativeUploadTextureArrayLayer(
1380
+ textureArrayId, gpuSlot, finalWidth, finalHeight, frameData, finalUseAstc,
1381
+ astcGlFormat, astcBlockWidth, astcBlockHeight);
1382
+ if (uploaded) {
1383
+ ringSlots[gpuSlot] = virtualPos;
1384
+ if (useVramStrategy) {
1385
+ frameCache.remove(virtualPos);
1386
+ }
1387
+ }
1388
+ };
1389
+
1390
+ // Main playback loop
1391
+ FrameClock clock = new FrameClock(frameIntervalNanos());
1392
+ // Only its parity matters: virtual positions are taken modulo 2 * totalFrames
1393
+ int playbackLoopIteration = 0;
1394
+ currentFrame = 0;
1395
+ boolean frameOwed = true;
1396
+ boolean wasPlaying = false;
1397
+ // Per pass, for tuning streamingPrefetchAhead: the fewest frames decoded after the shown
1398
+ // one, and how often playback waited for the decoder
1399
+ int fewestAhead = totalFrames;
1400
+ int stalls = 0;
1401
+ boolean stalled = false;
1402
+
1403
+ try {
1404
+ while (!shouldStop.get() && !isDestroyed.get()) {
1405
+ int seek = takeSeek();
1406
+ if (seek >= 0) {
1407
+ boolean samePass = decoderLoopIteration[0] % 2 == playbackLoopIteration % 2;
1408
+ // Frames from the shown one up to the decoder are buffered (in the cache or the ring)
1409
+ boolean buffered = samePass && seek >= currentFrame && seek < decodePos[0];
1410
+ if (!buffered) {
1411
+ // Carry on decoding when that is no further from the target than the
1412
+ // nearest seek point (the level start, without a seek table)
1413
+ long target = (long) seek * finalBytesPerLayer;
1414
+ long[] seekPoint = ZstdSeekTable.seekPointBefore(seekTable, target);
1415
+ long decoded = (long) decodePos[0] * finalBytesPerLayer
1416
+ + frameAccumulatorBuffer.position() - bytesToSkip[0];
1417
+ if (samePass && seek >= decodePos[0] && !decompressEnded[0] && decoded >= seekPoint[1]) {
1418
+ skipDecodedBytes(frameAccumulatorBuffer, bytesToSkip,
1419
+ (long) (seek - decodePos[0]) * finalBytesPerLayer);
1420
+ } else {
1421
+ decompressEnded[0] = !restartInternalZstdStream(
1422
+ streamId, local, finalLevel0Offset, finalLevel0Length, seekPoint[0]);
1423
+ frameAccumulatorBuffer.clear();
1424
+ bytesToSkip[0] = target - seekPoint[1];
1425
+ }
1426
+ decodePos[0] = seek;
1427
+ decoderLoopIteration[0] = 0;
1428
+ playbackLoopIteration = 0;
1429
+ dropStreamedFrames(frameCache, bufferPool, ringSlots, finalBytesPerLayer);
1430
+ }
1431
+ currentFrame = seek;
1432
+ frameOwed = true;
1433
+ wasPlaying = false;
1434
+ }
1435
+
1436
+ boolean playing = isPlaying.get();
1437
+ if (!playing && !frameOwed) {
1438
+ wasPlaying = false;
1439
+ Thread.sleep(16);
1440
+ continue;
1441
+ }
1442
+
1443
+ int virtualPlaybackPos = (playbackLoopIteration * totalFrames + currentFrame) % vPosModulus;
1444
+ decodeFrames.accept(virtualPlaybackPos);
1445
+ if (streamError[0] != null) {
1446
+ final String error = streamError[0];
1447
+ mainHandler.post(() -> notifyError(error));
1448
+ break;
1449
+ }
1450
+ evictOldFrames.accept(virtualPlaybackPos);
1451
+
1452
+ int currentGpuSlot = currentFrame % finalGpuCacheSize;
1453
+ boolean frameInGpu = ringSlots[currentGpuSlot] == virtualPlaybackPos;
1454
+ boolean frameInCpuCache = frameCache.containsKey(virtualPlaybackPos);
1455
+ if (!frameInGpu && !frameInCpuCache) {
1456
+ // Still catching up to a seek: decodeFrames goes on with it
1457
+ if (bytesToSkip[0] > 0 && !decompressEnded[0] && !inputPending[0]) continue;
1458
+ if (playing && !stalled) {
1459
+ stalled = true;
1460
+ stalls++;
1461
+ }
1462
+ // Bytes on their way are expected; anything else is a stall worth a warning
1463
+ if (!inputPending[0]) {
1464
+ Log.w(TAG, "Internal zstd frame not ready: frame=" + currentFrame + ", vPos=" + virtualPlaybackPos);
1465
+ }
1466
+ Thread.sleep(8);
1467
+ continue;
1468
+ }
1469
+
1470
+ ensureFrameInGpu.accept(virtualPlaybackPos);
1471
+ if (!useVramStrategy) {
1472
+ for (int i = 1; i <= 2; i++) {
1473
+ int nextVirtualPos = (virtualPlaybackPos + i) % vPosModulus;
1474
+ int nextGpuSlot = ((currentFrame + i) % totalFrames) % finalGpuCacheSize;
1475
+ // Past the loop point a frame can map to the shown frame's slot
1476
+ if (nextGpuSlot == currentGpuSlot) break;
1477
+ if (frameCache.containsKey(nextVirtualPos) || ringSlots[nextGpuSlot] == nextVirtualPos) {
1478
+ ensureFrameInGpu.accept(nextVirtualPos);
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ renderFrame();
1484
+ frameOwed = false;
1485
+ if (!playing) {
1486
+ wasPlaying = false;
1487
+ continue;
1488
+ }
1489
+ if (!wasPlaying) {
1490
+ clock.restart();
1491
+ wasPlaying = true;
1492
+ }
1493
+ stalled = false;
1494
+ int decodedVPos = (decoderLoopIteration[0] * totalFrames + decodePos[0]) % vPosModulus;
1495
+ int ahead = (decodedVPos - virtualPlaybackPos + vPosModulus) % vPosModulus - 1;
1496
+ fewestAhead = Math.min(fewestAhead, ahead);
1497
+ if (enableFrameEvents) {
1498
+ mainHandler.post(() -> notifyFrameUpdate());
1499
+ }
1500
+ clock.awaitNext();
1501
+
1502
+ if (currentFrame + 1 < totalFrames) {
1503
+ currentFrame++;
1504
+ } else {
1505
+ logI("Pass buffer: fewest frames ahead " + fewestAhead + " of " + PREFETCH_AHEAD
1506
+ + ", stalls " + stalls);
1507
+ fewestAhead = totalFrames;
1508
+ stalls = 0;
1509
+ if (loop) {
1510
+ currentFrame = 0;
1511
+ playbackLoopIteration = (playbackLoopIteration + 1) % 2;
1512
+ logI("Internal zstd loop transition: playbackLoop=" + playbackLoopIteration);
1513
+ } else {
1514
+ finish();
1515
+ }
1516
+ }
1517
+ }
1518
+ } catch (InterruptedException e) {
1519
+ Thread.currentThread().interrupt();
1520
+ } finally {
1521
+ AlphaVideoGpuNative.nativeStreamingDecompressEnd(streamId[0]);
1522
+ }
1523
+ logI("Internal zstd streaming animation ended");
1524
+ }
1525
+
1526
+ private boolean initGL(SurfaceTexture surfaceTexture) {
1527
+ eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
1528
+ if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
1529
+ Log.e(TAG, "Unable to get EGL display");
1530
+ return false;
1531
+ }
1532
+
1533
+ int[] version = new int[2];
1534
+ if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
1535
+ Log.e(TAG, "Unable to initialize EGL");
1536
+ return false;
1537
+ }
1538
+
1539
+ // EGL_OPENGL_ES3_BIT_KHR = 0x40 (not available in all SDK levels)
1540
+ int[] configAttribs = {
1541
+ EGL14.EGL_RED_SIZE, 8,
1542
+ EGL14.EGL_GREEN_SIZE, 8,
1543
+ EGL14.EGL_BLUE_SIZE, 8,
1544
+ EGL14.EGL_ALPHA_SIZE, 8,
1545
+ EGL14.EGL_RENDERABLE_TYPE, 0x40, // EGL_OPENGL_ES3_BIT_KHR
1546
+ EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
1547
+ EGL14.EGL_NONE
1548
+ };
1549
+
1550
+ EGLConfig[] configs = new EGLConfig[1];
1551
+ int[] numConfigs = new int[1];
1552
+ if (!EGL14.eglChooseConfig(eglDisplay, configAttribs, 0, configs, 0, 1, numConfigs, 0)) {
1553
+ Log.e(TAG, "Unable to choose EGL config");
1554
+ return false;
1555
+ }
1556
+
1557
+ int[] contextAttribs = {
1558
+ EGL14.EGL_CONTEXT_CLIENT_VERSION, 3,
1559
+ EGL14.EGL_NONE
1560
+ };
1561
+ eglContext = EGL14.eglCreateContext(eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0);
1562
+ if (eglContext == EGL14.EGL_NO_CONTEXT) {
1563
+ Log.e(TAG, "Unable to create EGL context");
1564
+ return false;
1565
+ }
1566
+
1567
+ int[] surfaceAttribs = { EGL14.EGL_NONE };
1568
+ eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, configs[0], surfaceTexture, surfaceAttribs, 0);
1569
+ if (eglSurface == EGL14.EGL_NO_SURFACE) {
1570
+ Log.e(TAG, "Unable to create EGL surface");
1571
+ return false;
1572
+ }
1573
+
1574
+ if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
1575
+ Log.e(TAG, "Unable to make EGL context current");
1576
+ return false;
1577
+ }
1578
+
1579
+ // Enable blending for transparency
1580
+ // Use premultiplied alpha blending (RGB is pre-multiplied by alpha in encoder)
1581
+ GLES30.glEnable(GLES30.GL_BLEND);
1582
+ GLES30.glBlendFunc(GLES30.GL_ONE, GLES30.GL_ONE_MINUS_SRC_ALPHA);
1583
+
1584
+ logD("OpenGL ES 3.0 initialized successfully");
1585
+ return true;
1586
+ }
1587
+
1588
+ private boolean detectASTCSupport() {
1589
+ String extensions = GLES30.glGetString(GLES30.GL_EXTENSIONS);
1590
+ if (extensions != null && extensions.contains("GL_KHR_texture_compression_astc_ldr")) {
1591
+ logI("The GPU decodes ASTC");
1592
+ return true;
1593
+ }
1594
+ Log.w(TAG, "No GPU ASTC support: decoding frames to RGBA in software");
1595
+ return false;
1596
+ }
1597
+
1598
+ private boolean initShaders() {
1599
+ int vertexShader = compileShader(GLES30.GL_VERTEX_SHADER, TEXTURE_ARRAY_VERTEX_SHADER);
1600
+ if (vertexShader == 0) {
1601
+ Log.e(TAG, "Failed to compile texture array vertex shader");
1602
+ return false;
1603
+ }
1604
+
1605
+ int fragmentShader = compileShader(GLES30.GL_FRAGMENT_SHADER, TEXTURE_ARRAY_FRAGMENT_SHADER);
1606
+ if (fragmentShader == 0) {
1607
+ GLES30.glDeleteShader(vertexShader);
1608
+ Log.e(TAG, "Failed to compile texture array fragment shader");
1609
+ return false;
1610
+ }
1611
+
1612
+ arrayProgram = GLES30.glCreateProgram();
1613
+ GLES30.glAttachShader(arrayProgram, vertexShader);
1614
+ GLES30.glAttachShader(arrayProgram, fragmentShader);
1615
+ GLES30.glLinkProgram(arrayProgram);
1616
+
1617
+ int[] linkStatus = new int[1];
1618
+ GLES30.glGetProgramiv(arrayProgram, GLES30.GL_LINK_STATUS, linkStatus, 0);
1619
+ if (linkStatus[0] != GLES30.GL_TRUE) {
1620
+ Log.e(TAG, "Texture array program link error: " + GLES30.glGetProgramInfoLog(arrayProgram));
1621
+ GLES30.glDeleteProgram(arrayProgram);
1622
+ arrayProgram = 0;
1623
+ return false;
1624
+ }
1625
+
1626
+ GLES30.glDeleteShader(vertexShader);
1627
+ GLES30.glDeleteShader(fragmentShader);
1628
+
1629
+ arrayUniformLayerLocation = GLES30.glGetUniformLocation(arrayProgram, "uLayer");
1630
+ logD("Texture array shader initialized (uLayer location: " + arrayUniformLayerLocation + ")");
1631
+ return true;
1632
+ }
1633
+
1634
+ private int compileShader(int type, String source) {
1635
+ int shader = GLES30.glCreateShader(type);
1636
+ GLES30.glShaderSource(shader, source);
1637
+ GLES30.glCompileShader(shader);
1638
+
1639
+ int[] compileStatus = new int[1];
1640
+ GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, compileStatus, 0);
1641
+ if (compileStatus[0] != GLES30.GL_TRUE) {
1642
+ Log.e(TAG, "Shader compile error: " + GLES30.glGetShaderInfoLog(shader));
1643
+ GLES30.glDeleteShader(shader);
1644
+ return 0;
1645
+ }
1646
+
1647
+ return shader;
1648
+ }
1649
+
1650
+ /** Takes the frame size, count and rate from the file; returns an error unless it plays. */
1651
+ private String applyMetadata(Ktx2Metadata metadata) {
1652
+ if (metadata == null) return "Not a KTX2 file";
1653
+ if (!metadata.isAstcArray()) {
1654
+ return "Only ASTC texture arrays play (this file has vkFormat " + metadata.vkFormat + " and "
1655
+ + metadata.frameCount + " layers); encode it with texenc";
1656
+ }
1657
+ frameWidth = metadata.width;
1658
+ frameHeight = metadata.height;
1659
+ totalFrames = metadata.frameCount;
1660
+ textureArrayLayerCount = metadata.frameCount;
1661
+ frameRate = metadata.frameRate;
1662
+ logI("Metadata: " + totalFrames + " frames, " + frameWidth + "x" + frameHeight + ", " + frameRate + " fps");
1663
+ return null;
1664
+ }
1665
+
1666
+ /**
1667
+ * Get supercompression scheme from KTX2 header.
1668
+ * Returns 0 for uncompressed, 2 for Zstd, or -1 on error.
1669
+ */
1670
+ private int getSupercompressionScheme(byte[] ktx2Data) {
1671
+ if (ktx2Data == null || ktx2Data.length < 48) return -1;
1672
+ ByteBuffer header = ByteBuffer.wrap(ktx2Data).order(ByteOrder.LITTLE_ENDIAN);
1673
+ return header.getInt(44);
1674
+ }
1675
+
1676
+ /**
1677
+ * Configure ASTC block dimensions and GL format from a KTX2 vkFormat value.
1678
+ * VkFormat 157-184 are ASTC UNORM/SRGB pairs for all 14 block sizes.
1679
+ */
1680
+ private void configureASTCFromVkFormat(int vkFormat) {
1681
+ // Normalize to base (UNORM) value
1682
+ int base = (vkFormat % 2 == 0) ? vkFormat - 1 : vkFormat;
1683
+ switch (base) {
1684
+ case 157: astcBlockWidth = 4; astcBlockHeight = 4; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_4x4_KHR; break;
1685
+ case 159: astcBlockWidth = 5; astcBlockHeight = 4; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_5x4_KHR; break;
1686
+ case 161: astcBlockWidth = 5; astcBlockHeight = 5; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_5x5_KHR; break;
1687
+ case 163: astcBlockWidth = 6; astcBlockHeight = 5; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_6x5_KHR; break;
1688
+ case 165: astcBlockWidth = 6; astcBlockHeight = 6; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_6x6_KHR; break;
1689
+ case 167: astcBlockWidth = 8; astcBlockHeight = 5; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_8x5_KHR; break;
1690
+ case 169: astcBlockWidth = 8; astcBlockHeight = 6; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_8x6_KHR; break;
1691
+ case 171: astcBlockWidth = 8; astcBlockHeight = 8; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_8x8_KHR; break;
1692
+ case 173: astcBlockWidth = 10; astcBlockHeight = 5; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_10x5_KHR; break;
1693
+ case 175: astcBlockWidth = 10; astcBlockHeight = 6; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_10x6_KHR; break;
1694
+ case 177: astcBlockWidth = 10; astcBlockHeight = 8; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_10x8_KHR; break;
1695
+ case 179: astcBlockWidth = 10; astcBlockHeight = 10; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_10x10_KHR; break;
1696
+ case 181: astcBlockWidth = 12; astcBlockHeight = 10; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_12x10_KHR; break;
1697
+ case 183: astcBlockWidth = 12; astcBlockHeight = 12; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_12x12_KHR; break;
1698
+ default: astcBlockWidth = 4; astcBlockHeight = 4; astcGlFormat = GL_COMPRESSED_RGBA_ASTC_4x4_KHR; break;
1699
+ }
1700
+ logI("ASTC format: " + astcBlockWidth + "x" + astcBlockHeight + " (vkFormat=" + vkFormat + ", glFormat=0x" + Integer.toHexString(astcGlFormat) + ")");
1701
+ }
1702
+
1703
+ /**
1704
+ * Calculate bytes per layer for the current ASTC block size.
1705
+ * All ASTC blocks are 16 bytes (128 bits) regardless of block dimensions.
1706
+ */
1707
+ private int astcBytesPerLayer(int width, int height) {
1708
+ int blocksX = (width + astcBlockWidth - 1) / astcBlockWidth;
1709
+ int blocksY = (height + astcBlockHeight - 1) / astcBlockHeight;
1710
+ return blocksX * blocksY * 16;
1711
+ }
1712
+
1713
+ /**
1714
+ * Load texture array to RAM without GPU upload (for RAM-heavy mode).
1715
+ * Returns Object[] = {width, height, layerCount, bytesPerLayer, ByteBuffer frameData}
1716
+ * Uses a single contiguous direct ByteBuffer for zero-copy GPU uploads.
1717
+ */
1718
+ private Object[] loadTextureArrayToRam(ByteBuffer ktx2Data, boolean useAstc) {
1719
+ try {
1720
+ // Parse KTX2 header to get dimensions and layer count
1721
+ if (ktx2Data.capacity() < 104) {
1722
+ Log.e(TAG, "KTX2 data too small for header");
1723
+ return null;
1724
+ }
1725
+
1726
+ ByteBuffer header = ktx2Data.duplicate().order(ByteOrder.LITTLE_ENDIAN);
1727
+
1728
+ // Check magic
1729
+ if (header.get(0) != (byte)0xAB || header.get(1) != 0x4B) {
1730
+ Log.e(TAG, "Invalid KTX2 magic");
1731
+ return null;
1732
+ }
1733
+
1734
+ int vkFormat = header.getInt(12);
1735
+ int pixelWidth = header.getInt(20);
1736
+ int pixelHeight = header.getInt(24);
1737
+ int layerCount = header.getInt(32);
1738
+ if (layerCount == 0) layerCount = 1;
1739
+ int supercompressionScheme = header.getInt(44); // Offset 44, not 48
1740
+
1741
+ // Configure ASTC format from vkFormat
1742
+ configureASTCFromVkFormat(vkFormat);
1743
+
1744
+ // Get level 0 data offset and length
1745
+ long level0ByteOffset = header.getLong(80);
1746
+ long level0ByteLength = header.getLong(88);
1747
+
1748
+ // Calculate ASTC frame size using dynamic block dimensions
1749
+ int bytesPerLayer = astcBytesPerLayer(pixelWidth, pixelHeight);
1750
+ int totalSize = bytesPerLayer * layerCount;
1751
+
1752
+ logD("KTX2 header: " + pixelWidth + "x" + pixelHeight + ", " + layerCount +
1753
+ " layers, supercompression=" + supercompressionScheme);
1754
+
1755
+ // Allocate direct ByteBuffer for zero-copy GPU uploads
1756
+ ByteBuffer frameBuffer = ByteBuffer.allocateDirect(totalSize);
1757
+ frameBuffer.order(ByteOrder.nativeOrder());
1758
+
1759
+ if (supercompressionScheme == 2) { // Zstd
1760
+ // Decompress directly into the direct ByteBuffer
1761
+ boolean success = AlphaVideoGpuNative.nativeDecompressZstdToBuffer(
1762
+ ktx2Data, level0ByteOffset, level0ByteLength, frameBuffer, totalSize);
1763
+ if (!success) {
1764
+ Log.e(TAG, "Zstd decompression to buffer failed");
1765
+ return null;
1766
+ }
1767
+ } else if (supercompressionScheme == 0) { // None
1768
+ // Copy uncompressed data directly to buffer
1769
+ ByteBuffer level = ktx2Data.duplicate();
1770
+ level.limit((int) (level0ByteOffset + level0ByteLength)).position((int) level0ByteOffset);
1771
+ frameBuffer.put(level);
1772
+ frameBuffer.rewind();
1773
+ } else {
1774
+ Log.e(TAG, "Unsupported supercompression: " + supercompressionScheme);
1775
+ return null;
1776
+ }
1777
+
1778
+ logD("Decoded " + layerCount + " frames to direct ByteBuffer, " +
1779
+ bytesPerLayer + " bytes each, total " + (totalSize / 1024) + " KB");
1780
+
1781
+ return new Object[] { pixelWidth, pixelHeight, layerCount, bytesPerLayer, frameBuffer };
1782
+ } catch (Exception e) {
1783
+ Log.e(TAG, "Failed to load texture array to RAM: " + e.getMessage());
1784
+ return null;
1785
+ }
1786
+ }
1787
+
1788
+ private void loadTextureArray(AlphaVideoGpuSource local) throws Exception {
1789
+ logD("Loading texture array from: " + local);
1790
+
1791
+ // Off the Java heap, whose cap (256 MB on many phones) a large clip can pass
1792
+ ByteBuffer ktx2Data = local.readAllDirect(getContext());
1793
+
1794
+ boolean useAstc = astcSupported;
1795
+
1796
+ // Check memory strategy for non-stream texture array mode
1797
+ // "ram" mode: Decode to RAM, upload per-frame (lower VRAM usage)
1798
+ // "vram" mode: Upload all to GPU
1799
+ boolean useRamMode = "ram".equals(memoryStrategy);
1800
+
1801
+ if (useRamMode) {
1802
+ logD("RAM-heavy mode: Decoding texture array to RAM, will upload per-frame");
1803
+
1804
+ // Decode all frames to RAM using nativeLoadTextureArrayToRam
1805
+ // This returns frame data in a contiguous direct ByteBuffer (zero-copy)
1806
+ Object[] ramResult = loadTextureArrayToRam(ktx2Data, useAstc);
1807
+ if (ramResult == null) {
1808
+ throw new Exception("Failed to decode texture array to RAM");
1809
+ }
1810
+
1811
+ int texWidth = (Integer) ramResult[0];
1812
+ int texHeight = (Integer) ramResult[1];
1813
+ int layerCount = (Integer) ramResult[2];
1814
+ ramBytesPerFrame = (Integer) ramResult[3];
1815
+ ramFrameBuffer = (ByteBuffer) ramResult[4];
1816
+ ramFrameCount = layerCount;
1817
+
1818
+ // Create small ring buffer on GPU (2 frames for double buffering)
1819
+ int gpuRingSize = 2;
1820
+ textureArrayId = AlphaVideoGpuNative.nativeCreateTextureArray(texWidth, texHeight, gpuRingSize, useAstc, astcGlFormat);
1821
+ if (textureArrayId == 0) {
1822
+ throw new Exception("Failed to create GPU ring buffer for RAM mode");
1823
+ }
1824
+ textureArrayLayerCount = gpuRingSize;
1825
+ ramHeavyMode = true;
1826
+ ramUploadedFrame = -1;
1827
+
1828
+ logD("RAM-heavy texture array: " + layerCount + " frames in direct ByteBuffer (" +
1829
+ (ramFrameCount * ramBytesPerFrame / 1024) + " KB), " +
1830
+ gpuRingSize + " slots in GPU ring buffer");
1831
+ } else {
1832
+ logD("VRAM mode: Loading texture array with ASTC=" + useAstc);
1833
+
1834
+ int[] result = AlphaVideoGpuNative.nativeLoadTextureArray(ktx2Data, useAstc);
1835
+ if (result == null || result.length < 4) {
1836
+ throw new Exception("Failed to load texture array");
1837
+ }
1838
+
1839
+ textureArrayId = result[0];
1840
+ int texWidth = result[1];
1841
+ int texHeight = result[2];
1842
+ textureArrayLayerCount = result[3];
1843
+ ramHeavyMode = false;
1844
+
1845
+ logD("Loaded texture array ID " + textureArrayId +
1846
+ " (" + texWidth + "x" + texHeight + ", " + textureArrayLayerCount + " layers)");
1847
+ }
1848
+ }
1849
+
1850
+ private void renderFrame() {
1851
+ if (eglDisplay == null || eglSurface == null || textureArrayId == 0) {
1852
+ return;
1853
+ }
1854
+ renderTextureArrayFrame();
1855
+ }
1856
+
1857
+ /**
1858
+ * Draws the current frame: samples its layer of the texture array.
1859
+ */
1860
+ private void renderTextureArrayFrame() {
1861
+ if (arrayProgram == 0) {
1862
+ Log.e(TAG, "Texture array shader not initialized");
1863
+ return;
1864
+ }
1865
+
1866
+ // RAM-heavy mode: Upload current frame to GPU ring buffer on-demand (zero-copy from direct ByteBuffer)
1867
+ if (ramHeavyMode && ramFrameBuffer != null && currentFrame < ramFrameCount) {
1868
+ if (ramUploadedFrame != currentFrame) {
1869
+ // Upload to slot 0 (single-buffered for simplicity)
1870
+ // Use zero-copy upload with offset into contiguous ByteBuffer
1871
+ int slot = 0;
1872
+ int offset = currentFrame * ramBytesPerFrame;
1873
+ boolean useAstc = astcSupported;
1874
+ boolean uploaded = AlphaVideoGpuNative.nativeUploadTextureArrayLayerFromBufferWithOffset(
1875
+ textureArrayId, slot, frameWidth, frameHeight, ramFrameBuffer, offset, ramBytesPerFrame, useAstc,
1876
+ astcGlFormat, astcBlockWidth, astcBlockHeight);
1877
+ if (uploaded) {
1878
+ ramUploadedFrame = currentFrame;
1879
+ }
1880
+ }
1881
+ }
1882
+
1883
+ GLES30.glClearColor(0, 0, 0, 0);
1884
+ GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT);
1885
+ GLES30.glViewport(0, 0, getWidth(), getHeight());
1886
+ GLES30.glUseProgram(arrayProgram);
1887
+
1888
+ // Set layer uniform:
1889
+ // - RAM-heavy mode: Always slot 0 (we upload current frame there)
1890
+ // - Streaming ring buffer mode: currentFrame % ringSize
1891
+ // - VRAM mode (non-stream or VRAM-heavy stream): currentFrame directly
1892
+ int layerIndex;
1893
+ if (ramHeavyMode) {
1894
+ layerIndex = 0; // Always render from slot 0 where we just uploaded
1895
+ } else if (streamingRingBufferMode) {
1896
+ layerIndex = currentFrame % streamingRingSize;
1897
+ } else {
1898
+ layerIndex = currentFrame;
1899
+ }
1900
+ GLES30.glUniform1f(arrayUniformLayerLocation, (float) layerIndex);
1901
+
1902
+ // Bind texture array
1903
+ GLES30.glActiveTexture(GLES30.GL_TEXTURE0);
1904
+ GLES30.glBindTexture(GLES30.GL_TEXTURE_2D_ARRAY, textureArrayId);
1905
+ int textureLocation = GLES30.glGetUniformLocation(arrayProgram, "uTextureArray");
1906
+ GLES30.glUniform1i(textureLocation, 0);
1907
+
1908
+ // Set vertex attributes
1909
+ int positionHandle = GLES30.glGetAttribLocation(arrayProgram, "aPosition");
1910
+ GLES30.glEnableVertexAttribArray(positionHandle);
1911
+ GLES30.glVertexAttribPointer(positionHandle, 2, GLES30.GL_FLOAT, false, 0, vertexBuffer);
1912
+
1913
+ int texCoordHandle = GLES30.glGetAttribLocation(arrayProgram, "aTexCoord");
1914
+ GLES30.glEnableVertexAttribArray(texCoordHandle);
1915
+ GLES30.glVertexAttribPointer(texCoordHandle, 2, GLES30.GL_FLOAT, false, 0, texCoordBuffer);
1916
+
1917
+ // Draw
1918
+ GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4);
1919
+
1920
+ GLES30.glDisableVertexAttribArray(positionHandle);
1921
+ GLES30.glDisableVertexAttribArray(texCoordHandle);
1922
+
1923
+ EGL14.eglSwapBuffers(eglDisplay, eglSurface);
1924
+ }
1925
+
1926
+ private void cleanupGL() {
1927
+ if (eglDisplay != null && eglDisplay != EGL14.EGL_NO_DISPLAY) {
1928
+ // The native side finds this view's PBOs by the current context
1929
+ if (pbosInitialized && textureArrayId != 0) {
1930
+ AlphaVideoGpuNative.nativeCleanupPBOs(textureArrayId);
1931
+ pbosInitialized = false;
1932
+ }
1933
+
1934
+ EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
1935
+
1936
+ // Destroying the context below frees the texture array and program
1937
+ textureArrayId = 0;
1938
+ arrayProgram = 0;
1939
+
1940
+ // Reset streaming ring buffer mode
1941
+ streamingRingBufferMode = false;
1942
+ streamingRingSize = 0;
1943
+
1944
+ // Clean up RAM-heavy mode resources
1945
+ ramFrameBuffer = null;
1946
+ ramBytesPerFrame = 0;
1947
+ ramFrameCount = 0;
1948
+ ramHeavyMode = false;
1949
+ ramUploadedFrame = -1;
1950
+
1951
+ if (eglSurface != null && eglSurface != EGL14.EGL_NO_SURFACE) {
1952
+ EGL14.eglDestroySurface(eglDisplay, eglSurface);
1953
+ eglSurface = null;
1954
+ }
1955
+
1956
+ if (eglContext != null && eglContext != EGL14.EGL_NO_CONTEXT) {
1957
+ EGL14.eglDestroyContext(eglDisplay, eglContext);
1958
+ eglContext = null;
1959
+ }
1960
+
1961
+ EGL14.eglTerminate(eglDisplay);
1962
+ eglDisplay = null;
1963
+ }
1964
+ }
1965
+
1966
+ private static boolean isRemote(String uri) {
1967
+ return uri.startsWith("https://") || uri.startsWith("http://");
1968
+ }
1969
+
1970
+ /**
1971
+ * What a source URI reads from: a local file in place, or for a URL or a data source a range
1972
+ * reader, opened here. Blocks on the network.
1973
+ */
1974
+ private AlphaVideoGpuSource resolveSource(String uri, String cacheKey) throws IOException {
1975
+ final String dataSourceName = mPlayingDataSource;
1976
+ final boolean appSource = dataSourceName != null;
1977
+ if (!appSource && !isRemote(uri)) return AlphaVideoGpuSource.parse(uri);
1978
+ if (!RangeSourceLink.available()) {
1979
+ if (appSource) {
1980
+ throw new NoSourceModuleException("The data source \"" + dataSourceName + "\" needs the "
1981
+ + "range-source add-on (dev.mediacache:range-source; for React Native, react-native-range-source), "
1982
+ + "which the app doesn't include");
1983
+ }
1984
+ warnWithoutAddOn();
1985
+ }
1986
+
1987
+ final Fetcher source;
1988
+ if (appSource) {
1989
+ source = AppDataSource.create(dataSourceName);
1990
+ if (source == null) {
1991
+ throw new IOException("No data source is registered as \"" + dataSourceName
1992
+ + "\" (RangeDataSources.register)");
1993
+ }
1994
+ } else {
1995
+ source = new HttpFetcher();
1996
+ }
1997
+ AlphaVideoGpuReader.Storage storage = storageFor(appSource);
1998
+ String storeDir = storage == AlphaVideoGpuReader.Storage.DISK
1999
+ ? new File(getContext().getCacheDir(), STORE_NAME).getPath() : STORE_NAME;
2000
+ String key = cacheKey != null && !cacheKey.isEmpty() ? cacheKey : uri;
2001
+ AlphaVideoGpuReader reader = AlphaVideoGpuReader.open(source, Uri.parse(uri), mPlayingHeaders, storage,
2002
+ storeDir, key, mMaxStorageBytes, getContext().getCacheDir().getPath());
2003
+ logD("Opened " + uri + " (" + reader.size() + " bytes, storage " + storage + ")");
2004
+ return AlphaVideoGpuSource.reader(reader, shouldStop);
2005
+ }
2006
+
2007
+ /** A data source without the range-source add-on: reported with {@link #ERROR_NO_SOURCE_MODULE}. */
2008
+ private static final class NoSourceModuleException extends IOException {
2009
+ NoSourceModuleException(String message) {
2010
+ super(message);
2011
+ }
2012
+ }
2013
+
2014
+ private void warnWithoutAddOn() {
2015
+ if (warnedWithoutAddOn.compareAndSet(false, true)) {
2016
+ Log.w(TAG, "range-source isn't installed (dev.mediacache:range-source; for React Native, "
2017
+ + "npm i react-native-range-source): URLs are downloaded whole for each play, without range "
2018
+ + "requests, storage, maxStorageBytes or cacheKey");
2019
+ }
2020
+ }
2021
+
2022
+ private AlphaVideoGpuReader.Storage storageFor(boolean appSource) {
2023
+ switch (mStorage) {
2024
+ case STORAGE_DISK: return AlphaVideoGpuReader.Storage.DISK;
2025
+ case STORAGE_MEMORY: return AlphaVideoGpuReader.Storage.MEMORY;
2026
+ case STORAGE_NONE: return AlphaVideoGpuReader.Storage.NONE;
2027
+ default: return appSource ? AlphaVideoGpuReader.Storage.NONE : AlphaVideoGpuReader.Storage.DISK;
2028
+ }
2029
+ }
2030
+
2031
+ /** What to report when a zstd stream fails: a read that failed after its retries, or corrupt data. */
2032
+ private static String streamFailure(AlphaVideoGpuSource local, long streamId) {
2033
+ String why = AlphaVideoGpuNative.nativeStreamingError(streamId);
2034
+ if (why == null) why = "unknown error";
2035
+ return local.reader() != null ? "Reading " + local + " failed: " + why : "Cannot decode " + local + ": " + why;
2036
+ }
2037
+
2038
+ /**
2039
+ * Points the stream at a zstd frame of the level (0 is its start), opening it again if an
2040
+ * earlier open failed. False when the stream can't be used.
2041
+ */
2042
+ private boolean restartInternalZstdStream(long[] streamId, AlphaVideoGpuSource local,
2043
+ long level0Offset, long level0Length, long compressedOffset) {
2044
+ if (streamId[0] == 0) {
2045
+ try {
2046
+ streamId[0] = openInternalZstdStream(local, level0Offset, level0Length);
2047
+ } catch (IOException e) {
2048
+ Log.e(TAG, "Cannot reopen " + local + ": " + e.getMessage());
2049
+ return false;
2050
+ }
2051
+ if (streamId[0] == 0) return false;
2052
+ }
2053
+ return AlphaVideoGpuNative.nativeStreamingRestartAt(streamId[0], compressedOffset);
2054
+ }
2055
+
2056
+ /**
2057
+ * Starts a zstd stream over the level0 data of an internally compressed KTX2: memory-mapped in
2058
+ * place, or read from the source's range reader.
2059
+ */
2060
+ private long openInternalZstdStream(AlphaVideoGpuSource local, long level0Offset, long level0Length) throws IOException {
2061
+ AlphaVideoGpuReader reader = local.reader();
2062
+ if (reader != null) {
2063
+ return AlphaVideoGpuNative.nativeReaderZstdStreamingInit(reader.handle(), level0Offset, level0Length);
2064
+ }
2065
+ try (AssetFileDescriptor afd = local.openFd(getContext())) {
2066
+ return AlphaVideoGpuNative.nativeInternalZstdStreamingInit(
2067
+ afd.getParcelFileDescriptor().getFd(), afd.getStartOffset() + level0Offset, level0Length);
2068
+ }
2069
+ }
2070
+
2071
+ private void logD(String message) {
2072
+ if (verbose) Log.d(TAG, message);
2073
+ }
2074
+
2075
+ private void logI(String message) {
2076
+ if (verbose) Log.i(TAG, message);
2077
+ }
2078
+
2079
+ // TextureView.SurfaceTextureListener implementation
2080
+ @Override
2081
+ public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
2082
+ logD("onSurfaceTextureAvailable: " + width + "x" + height);
2083
+ isDestroyed.set(false);
2084
+ shouldStop.set(false);
2085
+ // Always start playback if we have a source - handles screen navigation
2086
+ if (mSource != null) {
2087
+ startPlayback();
2088
+ }
2089
+ }
2090
+
2091
+ @Override
2092
+ public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
2093
+ logD("onSurfaceTextureSizeChanged: " + width + "x" + height);
2094
+ }
2095
+
2096
+ @Override
2097
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
2098
+ logD("onSurfaceTextureDestroyed");
2099
+ isDestroyed.set(true);
2100
+ shouldStop.set(true);
2101
+ isPlaying.set(false);
2102
+ isPlaybackRunning.set(false); // Reset for clean restart
2103
+ mSourceLoaded = false; // Reset so playback restarts when surface is available again
2104
+ shutdownExecutor();
2105
+ return true;
2106
+ }
2107
+
2108
+ @Override
2109
+ public void onSurfaceTextureUpdated(SurfaceTexture surface) {
2110
+ // No-op
2111
+ }
2112
+
2113
+ @Override
2114
+ protected void onDetachedFromWindow() {
2115
+ super.onDetachedFromWindow();
2116
+ logD("onDetachedFromWindow");
2117
+ destroy();
2118
+ }
2119
+
2120
+ @Override
2121
+ protected void onAttachedToWindow() {
2122
+ super.onAttachedToWindow();
2123
+ logD("onAttachedToWindow");
2124
+ isDestroyed.set(false);
2125
+ }
2126
+ }