656 lines
25 KiB
C
656 lines
25 KiB
C
// JNI bindings for libass. Exports use standard Java_<package>_<Class>_<method>
|
||
// naming so no RegisterNatives/JNI_OnLoad registration is needed.
|
||
#include <EGL/egl.h>
|
||
#include <EGL/eglext.h>
|
||
#include <android/log.h>
|
||
#include <jni.h>
|
||
#include <limits.h>
|
||
#include <stdarg.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <time.h>
|
||
|
||
static inline long long nowMs(void) {
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||
}
|
||
|
||
#include "ass/ass.h"
|
||
|
||
#define LOG_TAG "SubtitleRenderer"
|
||
|
||
static void assMessageCallback(int level, const char* fmt, va_list args, void* data) {
|
||
if (level > 4) return;
|
||
|
||
if (level >= 2) {
|
||
__android_log_vprint(ANDROID_LOG_WARN, LOG_TAG, fmt, args);
|
||
} else {
|
||
__android_log_vprint(ANDROID_LOG_ERROR, LOG_TAG, fmt, args);
|
||
}
|
||
}
|
||
|
||
// --- Ass (library) ---
|
||
|
||
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssInit(JNIEnv* env, jclass clazz) {
|
||
ASS_Library* assLibrary = ass_library_init();
|
||
ass_set_message_cb(assLibrary, assMessageCallback, NULL);
|
||
ass_set_extract_fonts(assLibrary, 1);
|
||
return (jlong)assLibrary;
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssAddFont(
|
||
JNIEnv* env, jclass clazz, jlong ass, jstring name, jbyteArray byteArray) {
|
||
jsize length = (*env)->GetArrayLength(env, byteArray);
|
||
jbyte* bytePtr = (*env)->GetByteArrayElements(env, byteArray, NULL);
|
||
if (bytePtr == NULL) {
|
||
return;
|
||
}
|
||
const char* cName = (*env)->GetStringUTFChars(env, name, NULL);
|
||
ass_add_font(((ASS_Library*)ass), cName, (char*)bytePtr, length);
|
||
(*env)->ReleaseByteArrayElements(env, byteArray, bytePtr, 0);
|
||
if (cName != NULL) {
|
||
(*env)->ReleaseStringUTFChars(env, name, cName);
|
||
}
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_Ass_nativeAssDeinit(JNIEnv* env, jclass clazz, jlong ass) {
|
||
if (ass) {
|
||
ass_library_done((ASS_Library*)ass);
|
||
}
|
||
}
|
||
|
||
// --- AssTrack ---
|
||
|
||
JNIEXPORT jlong JNICALL
|
||
Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackInit(JNIEnv* env, jclass clazz, jlong ass) {
|
||
ASS_Track* track = ass_new_track((ASS_Library*)ass);
|
||
if (track != NULL) {
|
||
if (ass_track_set_feature(track, ASS_FEATURE_FAST_BLUR, 1) != 0) {
|
||
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "ASS_FEATURE_FAST_BLUR unavailable in libass build");
|
||
}
|
||
}
|
||
return (jlong)track;
|
||
}
|
||
|
||
// Shared body of readBuffer/readChunk: pins the byte array and feeds libass.
|
||
// chunked != 0 routes to ass_process_chunk (timed dialogue), else ass_process_data.
|
||
static void processTrackBytes(
|
||
JNIEnv* env, jlong track, jbyteArray buffer, jint offset, jint length, jlong start, jlong duration, int chunked) {
|
||
if (!track) return;
|
||
jbyte* elements = (*env)->GetByteArrayElements(env, buffer, NULL);
|
||
if (elements == NULL) {
|
||
return;
|
||
}
|
||
if (chunked) {
|
||
ass_process_chunk((ASS_Track*)track, (char*)(elements + offset), length, start, duration);
|
||
} else {
|
||
ass_process_data((ASS_Track*)track, (char*)(elements + offset), length);
|
||
}
|
||
(*env)->ReleaseByteArrayElements(env, buffer, elements, 0);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackReadBuffer(
|
||
JNIEnv* env, jclass clazz, jlong track, jbyteArray buffer, jint offset, jint length) {
|
||
processTrackBytes(env, track, buffer, offset, length, 0, 0, 0);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackReadChunk(
|
||
JNIEnv* env, jclass clazz, jlong track, jlong start, jlong duration, jbyteArray buffer, jint offset, jint length) {
|
||
processTrackBytes(env, track, buffer, offset, length, start, duration, 1);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL
|
||
Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackDeinit(JNIEnv* env, jclass clazz, jlong track) {
|
||
if (!track) return;
|
||
ass_free_track((ASS_Track*)track);
|
||
}
|
||
|
||
// Earliest event Start strictly after afterMs, or -1. Lets the render pipeline
|
||
// pre-render (cache-warm) the next upcoming event during idle stretches so
|
||
// heavy typesetting doesn't pay its cache-cold rasterization at appearance.
|
||
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackNextEventStart(
|
||
JNIEnv* env, jclass clazz, jlong track, jlong afterMs) {
|
||
if (!track) return -1;
|
||
ASS_Track* t = (ASS_Track*)track;
|
||
long long best = -1;
|
||
for (int i = 0; i < t->n_events; i++) {
|
||
const long long start = t->events[i].Start;
|
||
if (start > afterMs && (best < 0 || start < best)) best = start;
|
||
}
|
||
return (jlong)best;
|
||
}
|
||
|
||
// Earliest visible-content boundary (event Start OR End) strictly after afterMs,
|
||
// or -1. A cache-warming prefetch is only invisible while no boundary passes:
|
||
// the render pipeline uses this to ensure nothing on screen is due to change
|
||
// before the event it is about to warm.
|
||
JNIEXPORT jlong JNICALL Java_com_edde746_plezy_libass_AssTrack_nativeAssTrackNextEventChange(
|
||
JNIEnv* env, jclass clazz, jlong track, jlong afterMs) {
|
||
if (!track) return -1;
|
||
ASS_Track* t = (ASS_Track*)track;
|
||
long long best = -1;
|
||
for (int i = 0; i < t->n_events; i++) {
|
||
const long long start = t->events[i].Start;
|
||
const long long end = start + t->events[i].Duration;
|
||
if (start > afterMs && (best < 0 || start < best)) best = start;
|
||
if (end > afterMs && (best < 0 || end < best)) best = end;
|
||
}
|
||
return (jlong)best;
|
||
}
|
||
|
||
// --- AssRender ---
|
||
|
||
// The fork's fontconfig build has no Android font search defaults. A tiny
|
||
// process-local config lets /system fonts resolve without adding Context/JNI plumbing.
|
||
static char* ensureFontsConf(void) {
|
||
const char* tmp = getenv("TMPDIR");
|
||
if (tmp == NULL || tmp[0] == '\0') tmp = "/data/local/tmp";
|
||
|
||
char cacheDir[PATH_MAX];
|
||
snprintf(cacheDir, sizeof(cacheDir), "%s/fontconfig", tmp);
|
||
mkdir(cacheDir, 0700);
|
||
|
||
char* confPath = (char*)malloc(PATH_MAX);
|
||
if (confPath == NULL) return NULL;
|
||
snprintf(confPath, PATH_MAX, "%s/fonts.conf", tmp);
|
||
|
||
FILE* f = fopen(confPath, "w");
|
||
if (f == NULL) {
|
||
free(confPath);
|
||
return NULL;
|
||
}
|
||
fprintf(
|
||
f,
|
||
"<?xml version=\"1.0\"?>\n"
|
||
"<!DOCTYPE fontconfig SYSTEM \"fonts.dtd\">\n"
|
||
"<fontconfig>\n"
|
||
" <dir>/system/fonts</dir>\n"
|
||
" <dir>/system/font</dir>\n"
|
||
" <dir>/product/fonts</dir>\n"
|
||
" <dir>/data/fonts</dir>\n"
|
||
" <cachedir>%s</cachedir>\n"
|
||
"</fontconfig>\n",
|
||
cacheDir);
|
||
fclose(f);
|
||
return confPath;
|
||
}
|
||
|
||
JNIEXPORT jlong JNICALL
|
||
Java_com_edde746_plezy_libass_AssRender_nativeAssRenderInit(JNIEnv* env, jclass clazz, jlong ass) {
|
||
ASS_Renderer* assRenderer = ass_renderer_init((ASS_Library*)ass);
|
||
if (assRenderer == NULL) return 0;
|
||
unsigned threads = ass_set_threads(assRenderer, 0);
|
||
if (threads == 0) {
|
||
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "libass threading unavailable in native build");
|
||
} else {
|
||
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "libass rendering threads enabled: %u", threads);
|
||
}
|
||
char* fontsConf = ensureFontsConf();
|
||
ass_set_fonts(assRenderer, NULL, "sans-serif", ASS_FONTPROVIDER_FONTCONFIG, fontsConf, 1);
|
||
free(fontsConf);
|
||
return (jlong)assRenderer;
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetFontScale(
|
||
JNIEnv* env, jclass clazz, jlong render, jfloat scale) {
|
||
if (!render) return;
|
||
ass_set_font_scale((ASS_Renderer*)render, scale);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetCacheLimit(
|
||
JNIEnv* env, jclass clazz, jlong render, jint glyphMax, jint bitmapMaxSize) {
|
||
if (!render) return;
|
||
ass_set_cache_limits((ASS_Renderer*)render, glyphMax, bitmapMaxSize);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetFrameSize(
|
||
JNIEnv* env, jclass clazz, jlong render, jint width, jint height) {
|
||
if (!render) return;
|
||
ass_set_frame_size((ASS_Renderer*)render, width, height);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetStorageSize(
|
||
JNIEnv* env, jclass clazz, jlong render, jint width, jint height) {
|
||
if (!render) return;
|
||
ass_set_storage_size((ASS_Renderer*)render, width, height);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetMargins(
|
||
JNIEnv* env, jclass clazz, jlong render, jint top, jint bottom, jint left, jint right) {
|
||
if (!render) return;
|
||
ass_set_margins((ASS_Renderer*)render, top, bottom, left, right);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderSetUseMargins(
|
||
JNIEnv* env, jclass clazz, jlong render, jboolean use) {
|
||
if (!render) return;
|
||
ass_set_use_margins((ASS_Renderer*)render, use ? 1 : 0);
|
||
}
|
||
|
||
JNIEXPORT void JNICALL
|
||
Java_com_edde746_plezy_libass_AssRender_nativeAssRenderDeinit(JNIEnv* env, jclass clazz, jlong render) {
|
||
if (render) {
|
||
ass_renderer_done((ASS_Renderer*)render);
|
||
}
|
||
}
|
||
|
||
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. Splitting wide
|
||
// or tall images into tiles lets a full-screen sign whose line bitmaps exceed
|
||
// the atlas pack completely instead of being dropped (issue #1436: a 4K-rendered
|
||
// sign produces line bitmaps wider than a 2048 atlas). Tiles are built in list
|
||
// order (= libass blend/painter order, preserved for pass 2); packing runs
|
||
// height-sorted via a separate key array so emission order is untouched.
|
||
typedef struct {
|
||
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
|
||
int ox, oy; // tile offset within the source bitmap
|
||
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
|
||
int sx, sy; // packed slot in the atlas; -1 if dropped for capacity
|
||
} PackTile;
|
||
|
||
typedef struct {
|
||
int th; // tile height (the sort key)
|
||
int idx; // index into the build-order tiles[] array
|
||
} TileSortKey;
|
||
|
||
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
|
||
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
|
||
}
|
||
|
||
static int imageListHasOutput(ASS_Image* image) {
|
||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||
if (img->w > 0 && img->h > 0) return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Throttle for truncation warnings (shared across renderers; logging only).
|
||
static int truncationLogCounter = 0;
|
||
|
||
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
|
||
//
|
||
// - atlasBuf holds packed ALPHA_8 pixels with row stride atlasMaxW. Only the first
|
||
// atlasHeight rows are written; the caller uploads that region to a texture
|
||
// allocated once at atlasMaxW × atlasMaxH.
|
||
// - vertexBuf holds a per-quad vertex stream (6 vertices × (2 pos + 2 uv + 4 color)
|
||
// floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in
|
||
// AssSubtitleAtlasPipeline.kt. Ready for a single glDrawArrays(GL_TRIANGLES, 0, N * 6).
|
||
// - UVs are normalized against atlasMaxW × atlasMaxH (the allocated texture dims),
|
||
// not the packed region, so the texture never needs reallocation.
|
||
// - Images are packed in height-sorted rows (minimizes packed height) but vertices
|
||
// are emitted in original list order — the list order is libass's painter order.
|
||
//
|
||
// Never fails on content size: images that don't fit the remaining atlas/vertex
|
||
// capacity are dropped and counted in AssAtlasFrame.truncated, so a heavy frame
|
||
// degrades instead of going stale. Returns NULL only for missing buffers/handles.
|
||
// On changed == 0, returns (0, 0, 0, changed, 0, hasOutput) without touching the
|
||
// buffers. hasOutput lets Kotlin distinguish "reuse the previous atlas" from
|
||
// "the current frame is blank and the GL surface must be cleared."
|
||
JNIEXPORT jobject JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFrameAtlas(
|
||
JNIEnv* env, jclass clazz, jlong render, jlong track, jlong time, jobject atlasBuf, jint atlasMaxW, jint atlasMaxH,
|
||
jobject vertexBuf) {
|
||
if (!render || !track || !atlasBuf || !vertexBuf || atlasMaxW <= 0 || atlasMaxH <= 0) return NULL;
|
||
|
||
jclass atlasFrameClass = (*env)->FindClass(env, "com/edde746/plezy/libass/AssAtlasFrame");
|
||
if (!atlasFrameClass) return NULL;
|
||
jmethodID ctor = (*env)->GetMethodID(env, atlasFrameClass, "<init>", "(IIIIIZ)V");
|
||
if (!ctor) return NULL;
|
||
|
||
const long long t0 = nowMs();
|
||
int changed;
|
||
ASS_Image* image = ass_render_frame((ASS_Renderer*)render, (ASS_Track*)track, time, &changed);
|
||
const long long tAss = nowMs();
|
||
|
||
if (changed == 0) {
|
||
const jboolean hasOutput = imageListHasOutput(image) ? JNI_TRUE : JNI_FALSE;
|
||
if (tAss - t0 > 40) {
|
||
__android_log_print(
|
||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time,
|
||
tAss - t0, changed, hasOutput == JNI_TRUE);
|
||
}
|
||
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, hasOutput);
|
||
}
|
||
|
||
if (image == NULL) {
|
||
if (tAss - t0 > 40) {
|
||
__android_log_print(
|
||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
|
||
tAss - t0, changed);
|
||
}
|
||
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, JNI_FALSE);
|
||
}
|
||
|
||
uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf);
|
||
jlong atlasCap = (*env)->GetDirectBufferCapacity(env, atlasBuf);
|
||
float* vertices = (float*)(*env)->GetDirectBufferAddress(env, vertexBuf);
|
||
jlong vertexCap = (*env)->GetDirectBufferCapacity(env, vertexBuf);
|
||
if (!atlasPixels || !vertices) return NULL;
|
||
if ((jlong)atlasMaxW * atlasMaxH > atlasCap) {
|
||
__android_log_print(
|
||
ANDROID_LOG_ERROR, LOG_TAG, "atlas buffer smaller than %dx%d (capacity %lld bytes)", atlasMaxW, atlasMaxH,
|
||
(long long)atlasCap);
|
||
return NULL;
|
||
}
|
||
// 48 floats per quad × 4 bytes = 192 bytes/quad
|
||
const int maxQuads = (int)(vertexCap / 192);
|
||
|
||
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
|
||
// tiles[] stays in list order (= blend/painter order for pass 2); keys[] is
|
||
// sorted by height so packing produces tight rows without disturbing it.
|
||
int total = 0;
|
||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||
if (img->w > 0 && img->h > 0) {
|
||
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
|
||
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
|
||
total += cols * rows;
|
||
}
|
||
}
|
||
if (total == 0) {
|
||
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, 0, JNI_FALSE);
|
||
}
|
||
|
||
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
|
||
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
|
||
if (!tiles || !keys) {
|
||
free(tiles);
|
||
free(keys);
|
||
return NULL;
|
||
}
|
||
int n = 0;
|
||
long long srcPixels = 0;
|
||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||
if (img->w <= 0 || img->h <= 0) continue;
|
||
srcPixels += (long long)img->w * img->h;
|
||
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
|
||
int th = img->h - oy;
|
||
if (th > atlasMaxH) th = atlasMaxH;
|
||
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
|
||
int tw = img->w - ox;
|
||
if (tw > atlasMaxW) tw = atlasMaxW;
|
||
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .sx = -1, .sy = -1};
|
||
keys[n] = (TileSortKey){.th = th, .idx = n};
|
||
n++;
|
||
}
|
||
}
|
||
}
|
||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
||
|
||
int cursorX = 0, cursorY = 0, rowH = 0;
|
||
int truncated = 0;
|
||
int packedH = 0;
|
||
int accepted = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
PackTile* t = &tiles[keys[i].idx];
|
||
if (t->tw <= atlasMaxW && accepted < maxQuads) {
|
||
int cx = cursorX, cy = cursorY, rh = rowH;
|
||
if (cx + t->tw > atlasMaxW) {
|
||
cy += rh;
|
||
cx = 0;
|
||
rh = 0;
|
||
}
|
||
if (cy + t->th <= atlasMaxH) {
|
||
t->sx = cx;
|
||
t->sy = cy;
|
||
cursorX = cx + t->tw;
|
||
cursorY = cy;
|
||
rowH = (t->th > rh) ? t->th : rh;
|
||
if (cy + t->th > packedH) packedH = cy + t->th;
|
||
accepted++;
|
||
}
|
||
}
|
||
if (t->sx < 0) truncated++;
|
||
}
|
||
|
||
if (truncated > 0 && (truncationLogCounter++ & 63) == 0) {
|
||
__android_log_print(
|
||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, %d quads max)", truncated, n,
|
||
atlasMaxW, atlasMaxH, maxQuads);
|
||
}
|
||
if (accepted == 0) {
|
||
free(tiles);
|
||
free(keys);
|
||
return (*env)->NewObject(env, atlasFrameClass, ctor, 0, 0, 0, changed, truncated, JNI_TRUE);
|
||
}
|
||
|
||
memset(atlasPixels, 0, (size_t)atlasMaxW * packedH);
|
||
|
||
// Pass 2: emit tiles in build order (= libass's painter/blend order), copying
|
||
// each placed tile into its slot and emitting its quad.
|
||
int qi = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
PackTile* t = &tiles[i];
|
||
if (t->sx < 0) continue;
|
||
ASS_Image* img = t->img;
|
||
const int px = t->sx;
|
||
const int py = t->sy;
|
||
|
||
for (int y = 0; y < t->th; y++) {
|
||
uint8_t* dst = atlasPixels + (size_t)(py + y) * atlasMaxW + px;
|
||
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
|
||
memcpy(dst, src, (size_t)t->tw);
|
||
}
|
||
|
||
const float x0 = (float)(img->dst_x + t->ox);
|
||
const float y0 = (float)(img->dst_y + t->oy);
|
||
const float x1 = x0 + (float)t->tw;
|
||
const float y1 = y0 + (float)t->th;
|
||
const float u0 = (float)px / (float)atlasMaxW;
|
||
const float v0 = (float)py / (float)atlasMaxH;
|
||
const float u1 = (float)(px + t->tw) / (float)atlasMaxW;
|
||
const float v1 = (float)(py + t->th) / (float)atlasMaxH;
|
||
const unsigned int c = img->color;
|
||
const float r = (float)((c >> 24) & 0xFFu) / 255.0f;
|
||
const float g = (float)((c >> 16) & 0xFFu) / 255.0f;
|
||
const float b = (float)((c >> 8) & 0xFFu) / 255.0f;
|
||
const float a = (float)(0xFFu - (c & 0xFFu)) / 255.0f;
|
||
|
||
float* vx = vertices + (size_t)qi * 48;
|
||
// 8 floats per vertex: x, y, u, v, r, g, b, a.
|
||
// Triangle 1: (x0,y0) (x1,y0) (x0,y1)
|
||
vx[0] = x0;
|
||
vx[1] = y0;
|
||
vx[2] = u0;
|
||
vx[3] = v0;
|
||
vx[4] = r;
|
||
vx[5] = g;
|
||
vx[6] = b;
|
||
vx[7] = a;
|
||
vx[8] = x1;
|
||
vx[9] = y0;
|
||
vx[10] = u1;
|
||
vx[11] = v0;
|
||
vx[12] = r;
|
||
vx[13] = g;
|
||
vx[14] = b;
|
||
vx[15] = a;
|
||
vx[16] = x0;
|
||
vx[17] = y1;
|
||
vx[18] = u0;
|
||
vx[19] = v1;
|
||
vx[20] = r;
|
||
vx[21] = g;
|
||
vx[22] = b;
|
||
vx[23] = a;
|
||
// Triangle 2: (x1,y0) (x1,y1) (x0,y1)
|
||
vx[24] = x1;
|
||
vx[25] = y0;
|
||
vx[26] = u1;
|
||
vx[27] = v0;
|
||
vx[28] = r;
|
||
vx[29] = g;
|
||
vx[30] = b;
|
||
vx[31] = a;
|
||
vx[32] = x1;
|
||
vx[33] = y1;
|
||
vx[34] = u1;
|
||
vx[35] = v1;
|
||
vx[36] = r;
|
||
vx[37] = g;
|
||
vx[38] = b;
|
||
vx[39] = a;
|
||
vx[40] = x0;
|
||
vx[41] = y1;
|
||
vx[42] = u0;
|
||
vx[43] = v1;
|
||
vx[44] = r;
|
||
vx[45] = g;
|
||
vx[46] = b;
|
||
vx[47] = a;
|
||
|
||
qi++;
|
||
}
|
||
|
||
free(tiles);
|
||
free(keys);
|
||
|
||
// Slow-render breakdown: separates libass's own cost (rasterize/blur/shape)
|
||
// from this function's packing + memcpy, so device logs attribute the time.
|
||
const long long tEnd = nowMs();
|
||
if (tEnd - t0 > 40) {
|
||
__android_log_print(
|
||
ANDROID_LOG_WARN, LOG_TAG,
|
||
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk atlas=%dx%d quads=%d",
|
||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, packedH, qi);
|
||
}
|
||
|
||
// atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width);
|
||
// atlasHeight is the packed height — the only rows worth uploading.
|
||
return (*env)->NewObject(env, atlasFrameClass, ctor, atlasMaxW, packedH, qi, changed, truncated, JNI_TRUE);
|
||
}
|
||
|
||
// --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) ---
|
||
//
|
||
// Measures the overlay buffer's ACTUAL on-screen present time so subtitle
|
||
// frame-perfection can be checked against the video frame's release time as
|
||
// ground truth, instead of the queue time (eglSwapBuffers return) the swap loop
|
||
// otherwise sees. The Java EGLExt only exposes eglPresentationTimeANDROID, so the
|
||
// frame-timestamp entry points are resolved here via eglGetProcAddress.
|
||
//
|
||
// All functions run on the GL thread with the pipeline's EGL context current.
|
||
|
||
// Older NDK eglext.h may predate the extension; fall back to the spec values.
|
||
#ifndef EGL_TIMESTAMPS_ANDROID
|
||
#define EGL_TIMESTAMPS_ANDROID 0x3430
|
||
#endif
|
||
#ifndef EGL_COMPOSITION_LATCH_TIME_ANDROID
|
||
#define EGL_COMPOSITION_LATCH_TIME_ANDROID 0x3436
|
||
#endif
|
||
#ifndef EGL_FIRST_COMPOSITION_START_TIME_ANDROID
|
||
#define EGL_FIRST_COMPOSITION_START_TIME_ANDROID 0x3437
|
||
#endif
|
||
#ifndef EGL_DISPLAY_PRESENT_TIME_ANDROID
|
||
#define EGL_DISPLAY_PRESENT_TIME_ANDROID 0x343A
|
||
#endif
|
||
#ifndef EGL_TIMESTAMP_INVALID_ANDROID
|
||
#define EGL_TIMESTAMP_INVALID_ANDROID (-1)
|
||
#endif
|
||
#ifndef EGL_TIMESTAMP_PENDING_ANDROID
|
||
#define EGL_TIMESTAMP_PENDING_ANDROID (-2)
|
||
#endif
|
||
#ifndef EGL_ANDROID_get_frame_timestamps
|
||
typedef khronos_stime_nanoseconds_t EGLnsecsANDROID;
|
||
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETNEXTFRAMEIDANDROIDPROC)(EGLDisplay, EGLSurface, EGLuint64KHR*);
|
||
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETFRAMETIMESTAMPSANDROIDPROC)(
|
||
EGLDisplay, EGLSurface, EGLuint64KHR, EGLint, const EGLint*, EGLnsecsANDROID*);
|
||
typedef EGLBoolean(EGLAPIENTRYP PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC)(EGLDisplay, EGLSurface, EGLint);
|
||
#endif
|
||
|
||
// nativeInit status: success codes are the chosen timestamp source (≥ 0); the
|
||
// actual display present time on code 0, and SurfaceFlinger composition timestamps
|
||
// (a near-constant ~1-vsync earlier than scanout) on codes 1/2 — a constant bias
|
||
// that doesn't hide the inter-layer jitter / multi-vsync outliers we look for.
|
||
// Negative codes are failure reasons surfaced to the stats path for diagnosis.
|
||
#define FT_SRC_PRESENT 0
|
||
#define FT_SRC_COMPOSITION_START 1
|
||
#define FT_SRC_COMPOSITION_LATCH 2
|
||
#define FT_ERR_NO_SURFACE (-1)
|
||
#define FT_ERR_NO_EXTENSION (-2)
|
||
#define FT_ERR_NO_PROC (-3)
|
||
#define FT_ERR_UNSUPPORTED (-4)
|
||
#define FT_ERR_ENABLE_FAILED (-5)
|
||
|
||
static PFNEGLGETNEXTFRAMEIDANDROIDPROC pEglGetNextFrameId = NULL;
|
||
static PFNEGLGETFRAMETIMESTAMPSANDROIDPROC pEglGetFrameTimestamps = NULL;
|
||
static PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC pEglGetFrameTimestampSupported = NULL;
|
||
static EGLDisplay gFtDisplay = EGL_NO_DISPLAY;
|
||
static EGLSurface gFtSurface = EGL_NO_SURFACE;
|
||
static EGLint gFtPresentName = EGL_DISPLAY_PRESENT_TIME_ANDROID;
|
||
|
||
// Probes the extension on the currently-current draw surface and enables capture.
|
||
// Re-resolves the display/surface each call so surface recreation is handled.
|
||
// Returns one of the FT_* codes above.
|
||
JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeInit(JNIEnv* env, jclass clazz) {
|
||
gFtSurface = EGL_NO_SURFACE;
|
||
EGLDisplay dpy = eglGetCurrentDisplay();
|
||
EGLSurface surf = eglGetCurrentSurface(EGL_DRAW);
|
||
if (dpy == EGL_NO_DISPLAY || surf == EGL_NO_SURFACE) return FT_ERR_NO_SURFACE;
|
||
|
||
const char* exts = eglQueryString(dpy, EGL_EXTENSIONS);
|
||
if (exts == NULL || strstr(exts, "EGL_ANDROID_get_frame_timestamps") == NULL) {
|
||
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: extension not present");
|
||
return FT_ERR_NO_EXTENSION;
|
||
}
|
||
if (pEglGetNextFrameId == NULL) {
|
||
pEglGetNextFrameId = (PFNEGLGETNEXTFRAMEIDANDROIDPROC)eglGetProcAddress("eglGetNextFrameIdANDROID");
|
||
pEglGetFrameTimestamps = (PFNEGLGETFRAMETIMESTAMPSANDROIDPROC)eglGetProcAddress("eglGetFrameTimestampsANDROID");
|
||
pEglGetFrameTimestampSupported =
|
||
(PFNEGLGETFRAMETIMESTAMPSUPPORTEDANDROIDPROC)eglGetProcAddress("eglGetFrameTimestampSupportedANDROID");
|
||
}
|
||
if (pEglGetNextFrameId == NULL || pEglGetFrameTimestamps == NULL || pEglGetFrameTimestampSupported == NULL) {
|
||
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "frame-timestamps: entry points unresolved");
|
||
return FT_ERR_NO_PROC;
|
||
}
|
||
|
||
// Prefer true display present; many TV HWCs (e.g. Amlogic) don't report present
|
||
// fences but do report SurfaceFlinger composition timestamps. Take the first
|
||
// supported — composition timing still pins the swap to a vsync for A-vs-B.
|
||
jint status;
|
||
if (pEglGetFrameTimestampSupported(dpy, surf, EGL_DISPLAY_PRESENT_TIME_ANDROID)) {
|
||
gFtPresentName = EGL_DISPLAY_PRESENT_TIME_ANDROID;
|
||
status = FT_SRC_PRESENT;
|
||
} else if (pEglGetFrameTimestampSupported(dpy, surf, EGL_FIRST_COMPOSITION_START_TIME_ANDROID)) {
|
||
gFtPresentName = EGL_FIRST_COMPOSITION_START_TIME_ANDROID;
|
||
status = FT_SRC_COMPOSITION_START;
|
||
} else if (pEglGetFrameTimestampSupported(dpy, surf, EGL_COMPOSITION_LATCH_TIME_ANDROID)) {
|
||
gFtPresentName = EGL_COMPOSITION_LATCH_TIME_ANDROID;
|
||
status = FT_SRC_COMPOSITION_LATCH;
|
||
} else {
|
||
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: no supported timestamp name");
|
||
return FT_ERR_UNSUPPORTED;
|
||
}
|
||
|
||
if (!eglSurfaceAttrib(dpy, surf, EGL_TIMESTAMPS_ANDROID, EGL_TRUE)) {
|
||
__android_log_print(ANDROID_LOG_WARN, LOG_TAG, "frame-timestamps: enable failed 0x%x", eglGetError());
|
||
return FT_ERR_ENABLE_FAILED;
|
||
}
|
||
gFtDisplay = dpy;
|
||
gFtSurface = surf;
|
||
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "frame-timestamps: enabled (source=%d)", status);
|
||
return status;
|
||
}
|
||
|
||
// Frame id the next eglSwapBuffers will produce; call immediately before it.
|
||
JNIEXPORT jlong JNICALL
|
||
Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetNextFrameId(JNIEnv* env, jclass clazz) {
|
||
if (pEglGetNextFrameId == NULL || gFtSurface == EGL_NO_SURFACE) return -1;
|
||
EGLuint64KHR id = 0;
|
||
if (!pEglGetNextFrameId(gFtDisplay, gFtSurface, &id)) return -1;
|
||
return (jlong)id;
|
||
}
|
||
|
||
// Present (or composition) time for frameId (System.nanoTime() domain), or the
|
||
// PENDING(-2)/INVALID(-1) sentinels. Reported a few frames after the swap.
|
||
JNIEXPORT jlong JNICALL
|
||
Java_com_edde746_plezy_libass_AssFrameTimestamps_nativeGetDisplayPresentTime(JNIEnv* env, jclass clazz, jlong frameId) {
|
||
if (pEglGetFrameTimestamps == NULL || gFtSurface == EGL_NO_SURFACE) return EGL_TIMESTAMP_INVALID_ANDROID;
|
||
const EGLint names[1] = {gFtPresentName};
|
||
EGLnsecsANDROID values[1] = {0};
|
||
if (!pEglGetFrameTimestamps(gFtDisplay, gFtSurface, (EGLuint64KHR)frameId, 1, names, values)) {
|
||
// Frame id evicted from SurfaceFlinger's history, or bad surface.
|
||
return EGL_TIMESTAMP_INVALID_ANDROID;
|
||
}
|
||
return (jlong)values[0];
|
||
}
|