diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c index db5a2af31..b8a2a50b2 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c @@ -282,12 +282,52 @@ struct basis_decoder { int64_t seekTargetUs; /* atomic */ int64_t seekFromUs; /* pre-seek audio front, for the audio-only settle (main thread only) */ int audioSeekGen; /* audio-submit (demux) thread only */ + int64_t aOutPtsBiasUs; /* audio-submit thread only: correction added to codec + * output PTS before it banks into the ring. The MP3 + * software decoder stamps outputs from an internal + * first-input anchor plus a sample accumulator and + * ignores later input-PTS jumps — the anchor survives + * a flush — so post-seek outputs keep pre-seek time + * (frozen position bar; backward seeks overrun the + * duration). Measured per seek as first post-flush + * input PTS minus first post-flush output PTS; ~0 for + * decoders that pass input PTS through. */ + int64_t aResyncInPts; /* audio-submit thread only: first post-flush input + * PTS, awaiting its output to measure the bias; + * INT64_MIN = no measurement pending */ + int aResyncPending; /* audio-submit thread only: seek flush done, latch + * aResyncInPts from the next queued input */ int videoSeekGen; /* video-submit (demux) thread only */ int renderSeekGen; /* render thread only */ int videoSeekAck; /* atomic: demux publishes seekGen once it has flushed the * codec + released pre-seek frames; the render leg holds * until it matches so it neither anchors to nor deletes a * post-seek frame the producer has already enqueued */ + int64_t vPrerollCutUs; /* video-submit thread only: after a seek, decoded frames + * short of this are the keyframe run-up to the target — + * reference-only, released unrendered. Set from + * seekTargetUs at the seek flush, cleared by the first + * frame at or past it. */ + int vAwaitKey; /* video-submit thread only: set at the seek flush, cleared + * by the first OUTPUT of the first keyframe submitted after + * it (matched by PTS via vAwaitKeyPts). Output before that + * is post-flush mid-GOP input decoded against stale + * reference memory (Adreno emits it as a visible pre-seek + * flash; the HLS path can still hand over a pre-seek tail + * AU the engine's seek_taken gate doesn't cover) — released + * unrendered, and it must not end the preroll run-up. + * Clearing at the keyframe's SUBMISSION is not enough: a + * tail AU queued just before it can still emit its garbage + * frame afterwards, and with a pre-seek PTS past the target + * that frame would both show and end the run-up. */ + int64_t vAwaitKeyPts; /* video-submit thread only: PTS of that keyframe; + * INT64_MIN until it is submitted */ + int vAwaitDrained; /* video-submit thread only: outputs drained since the + * seek flush; bounds the wait so a dropped or + * re-stamped keyframe output — or a run whose post-seek + * AUs are never flagged as keyframes, which would + * otherwise never latch vAwaitKeyPts — can't hold the + * gate (and video) shut until the next seek */ /* debug counters */ long dbg_render, dbg_nodue, dbg_acqfail, dbg_drop, dbg_lagms; @@ -319,6 +359,14 @@ static void on_image(void* ctx, AImageReader* reader) { int64_t ts_ns = 0; AImage_getTimestamp(img, &ts_ns); /* MediaCodec propagates the input PTS (ns) */ + /* Seek-generation tag (sub-microsecond digits, written at release): + * a mismatch means this frame crossed a seek flush in flight — showing + * it would anchor the present clock at the pre-seek position. */ + { + int tag = (int)(((ts_ns % 1000) + 1000) % 1000); + int gen = ((__atomic_load_n(&d->seekGen, __ATOMIC_ACQUIRE) % 1000) + 1000) % 1000; + if (tag != gen) { AImage_delete(img); continue; } + } int64_t pts = ts_ns / 1000; int32_t aw = 0, ah = 0; AImage_getWidth(img, &aw); AImage_getHeight(img, &ah); @@ -419,15 +467,57 @@ static int ensure_reader(basis_decoder_t* d, int w, int h) { /* ---- output draining (push decoded frames to the Surface) --------------- */ -static void drain_video_output(basis_decoder_t* d) { - if (!d->vcodec) return; +/* Returns 1 once the codec has emitted its end-of-stream output (only after + * basis_decoder_notify_end_of_stream queued the EOS input); 0 otherwise. */ +static int drain_video_output(basis_decoder_t* d) { + if (!d->vcodec) return 0; + int eos = 0; for (;;) { AMediaCodecBufferInfo info; ssize_t oi = AMediaCodec_dequeueOutputBuffer(d->vcodec, &info, 0); if (oi >= 0) { + if (info.flags & AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM) eos = 1; d->lastPtsUs = info.presentationTimeUs; - /* render=true pushes the frame onto the AImageReader Surface */ - AMediaCodec_releaseOutputBuffer(d->vcodec, oi, info.size != 0); + /* render=true pushes the frame onto the AImageReader Surface. Post-seek + * preroll (keyframe run-up short of the target) is decoded so later + * frames have their references but released unrendered; output is + * display-order, so the first frame at or past the target ends the + * run-up for good — but only once the post-flush keyframe's own + * output has emerged: output before that is post-flush mid-GOP + * garbage whose PTS may sit past the target, and it must neither + * show nor end the run-up. */ + int render = info.size != 0; + /* The PTS match is the designed clear (the keyframe's own output; the + * cut below gates it). The drain bound is a backstop, set well past any + * plausible garbage-tail length: past it the cut still suppresses the + * run-up, so the worst case is one stale frame — degraded, not wedged. */ + if (d->vAwaitKey && + ((d->vAwaitKeyPts != INT64_MIN && info.presentationTimeUs == d->vAwaitKeyPts) || + ++d->vAwaitDrained > 16)) + d->vAwaitKey = 0; + if (d->vAwaitKey) { + render = 0; + } else if (d->vPrerollCutUs != INT64_MIN) { + if (info.presentationTimeUs < d->vPrerollCutUs) render = 0; + else d->vPrerollCutUs = INT64_MIN; + } + if (render) { + /* Tag the frame with the seek generation in the sub-microsecond + * digits of the surface timestamp (the PTS rides in whole + * microseconds, so on_image's ts/1000 is untouched). A frame + * still in flight through the AImageReader listener when a seek + * flushes carries the old tag and dies at on_image instead of + * presenting its pre-seek PTS and mis-anchoring the clock. + * The tag is this thread's videoSeekGen, which advances only in + * the seek-flush block: a pre-seek frame drained after the seek + * posts but before the flush runs still carries the old tag, + * where the live seekGen would stamp it as post-seek content. */ + int64_t tag = (int64_t)(((d->videoSeekGen % 1000) + 1000) % 1000); + AMediaCodec_releaseOutputBufferAtTime(d->vcodec, oi, + info.presentationTimeUs * 1000 + tag); + } else { + AMediaCodec_releaseOutputBuffer(d->vcodec, oi, 0); + } } else if (oi == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED) { AMediaFormat* f = AMediaCodec_getOutputFormat(d->vcodec); int32_t w = 0, h = 0; @@ -451,6 +541,42 @@ static void drain_video_output(basis_decoder_t* d) { break; /* try again later / no buffer */ } } + return eos; +} + +void basis_decoder_notify_end_of_stream(basis_decoder_t* d) { + if (!d || !d->vcodec) return; + /* Caller is the video-submit (demux) thread, which owns vcodec — same + * ownership as submit_video. MediaCodec flushes asynchronously after the + * EOS input, so pump the output side (bounded) until the EOS-flagged + * buffer emerges rather than trusting a single drain pass. + * TRY_AGAIN_LATER from dequeueInputBuffer is routine while the codec is + * busy, so retry to a short deadline (draining outputs frees input slots). + * If the EOS input still can't be queued, the retained tail stays in the + * codec, presentation_pending never sees it, and the core's drain-wait + * ends on its idle cap — degraded, not wedged. */ + ssize_t ii = -1; + for (int i = 0; i < 50 && ii < 0; ++i) { /* ~500ms deadline */ + ii = AMediaCodec_dequeueInputBuffer(d->vcodec, 10000); + if (ii < 0) drain_video_output(d); + } + if (ii < 0) return; + if (AMediaCodec_queueInputBuffer(d->vcodec, ii, 0, 0, 0, + AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM) != AMEDIA_OK) return; + for (int i = 0; i < 100; ++i) { /* ~1s cap */ + if (drain_video_output(d)) return; + usleep(10000); + } +} + +int basis_decoder_presentation_pending(basis_decoder_t* d) { + if (!d) return 0; + int pending = 0; + pthread_mutex_lock(&d->vm); + for (int i = 0; i < VRING; ++i) if (d->vimg[i]) { pending = 1; break; } + pthread_mutex_unlock(&d->vm); + if (!pending) pending = ring_fill_ms(&d->pcm) > 0; + return pending; } static void drain_audio_output(basis_decoder_t* d) { @@ -462,7 +588,14 @@ static void drain_audio_output(basis_decoder_t* d) { size_t cap = 0; uint8_t* buf = AMediaCodec_getOutputBuffer(d->acodec, oi, &cap); if (buf && info.size >= 2) { - int64_t pts = info.presentationTimeUs; + /* First output after a seek flush: its input PTS is known, so any + * difference is the decoder's own timeline drifting from the demux + * timeline — cancel it for the rest of this seek generation. */ + if (d->aResyncInPts != INT64_MIN) { + d->aOutPtsBiasUs = d->aResyncInPts - info.presentationTimeUs; + d->aResyncInPts = INT64_MIN; + } + int64_t pts = info.presentationTimeUs + d->aOutPtsBiasUs; int frame = d->ach > 0 ? d->ach : (d->pcm.frame > 0 ? d->pcm.frame : 2); int srr = d->asr > 0 ? d->asr : 48000; if (d->apcm_float) { @@ -554,6 +687,10 @@ static void* url_worker(void* arg) { basis_engine_set_state(d->engine, BASIS_MEDIA_STATE_PLAYING); while (basis_engine_is_running(d->engine)) { if (basis_engine_is_paused(d->engine)) { usleep(10000); continue; } + /* No seek support on this path (no seekTo, no flush), but the drain's + * frame tag reads videoSeekGen — keep it tracking seekGen so a seek + * request can't leave every subsequent frame tagged stale. */ + d->videoSeekGen = __atomic_load_n(&d->seekGen, __ATOMIC_ACQUIRE); int track = AMediaExtractor_getSampleTrackIndex(d->extractor); if (track == d->video_track && d->vcodec) feed_extractor_sample(d, d->vcodec, track); @@ -608,7 +745,12 @@ basis_decoder_t* basis_decoder_create(basis_media_engine_t* engine) { pthread_mutex_init(&d->vm, NULL); d->lastPresentedPts = INT64_MIN; d->presentedPosUs = -1; + d->vPrerollCutUs = INT64_MIN; + d->vAwaitKey = 0; + d->vAwaitKeyPts = INT64_MIN; + d->vAwaitDrained = 0; d->prevWritePts = INT64_MIN; + d->aResyncInPts = INT64_MIN; d->audClockOffsetUs = INT64_MIN; d->bufferUs = 120000; d->bufferMode = 1; @@ -888,7 +1030,6 @@ int basis_decoder_set_audio_format(basis_decoder_t* d, basis_codec_t codec, } int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* annexb, int len, int64_t pts_us, int key) { - (void)key; if (!d || !d->vcodec || !annexb || len <= 0) return -1; /* First video AU after a seek: flush the codec and release the pre-seek frames * in the ring so they can't present ahead of the post-seek content. Demux thread @@ -898,14 +1039,23 @@ int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* annexb, int le if (svg != d->videoSeekGen) { d->videoSeekGen = svg; AMediaCodec_flush(d->vcodec); + d->vPrerollCutUs = __atomic_load_n(&d->seekTargetUs, __ATOMIC_ACQUIRE); + d->vAwaitKey = 1; + d->vAwaitKeyPts = INT64_MIN; + d->vAwaitDrained = 0; pthread_mutex_lock(&d->vm); for (int i = 0; i < VRING; ++i) if (d->vimg[i]) { AImage_delete(d->vimg[i]); d->vimg[i] = NULL; } pthread_mutex_unlock(&d->vm); + /* Frames released to the Surface before the flush can still be in flight + * through the AImageReader listener and would land after the clear + * above; on_image drops them by their seek-generation timestamp tag, + * so no quiescence wait is needed here. */ /* Publish the generation so the render leg knows the pre-seek frames are gone * and it can re-anchor. Releasing frames on this (owning) thread only stops * the render leg from deleting post-seek frames drain_video_output re-enqueues. */ __atomic_store_n(&d->videoSeekAck, svg, __ATOMIC_RELEASE); } + if (key && d->vAwaitKey && d->vAwaitKeyPts == INT64_MIN) d->vAwaitKeyPts = pts_us; int rc = -1; ssize_t ii = AMediaCodec_dequeueInputBuffer(d->vcodec, 2000); if (ii >= 0) { @@ -987,6 +1137,8 @@ int basis_decoder_submit_audio(basis_decoder_t* d, const uint8_t* data, int len, d->audioSeekGen = sg; ring_flush(&d->pcm); if (d->acodec) AMediaCodec_flush(d->acodec); + d->aResyncPending = 1; + d->aResyncInPts = INT64_MIN; } if (d->ac == BASIS_CODEC_LPCM) { submit_lpcm(d, data, len, pts_us); return 0; } if (!d->acodec) return -1; @@ -998,6 +1150,7 @@ int basis_decoder_submit_audio(basis_decoder_t* d, const uint8_t* data, int len, if (buf && (size_t)len <= cap) { memcpy(buf, data, (size_t)len); rc = (AMediaCodec_queueInputBuffer(d->acodec, ii, 0, len, pts_us, 0) == AMEDIA_OK) ? 0 : -1; + if (rc == 0 && d->aResyncPending) { d->aResyncPending = 0; d->aResyncInPts = pts_us; } } else { /* Never feed a partial frame — it decodes to an error + silence. * max-input-size should prevent this; return the buffer empty if not. */ @@ -1268,6 +1421,15 @@ void basis_decoder_seek(basis_decoder_t* d, int64_t target_us) { * mutex, safe from this (caller) thread; the codec reset stays on the submit * thread where the decoder is owned. */ ring_flush(&d->pcm); + /* Invalidate the audio serve clock: it re-derives from presents, and until the + * first post-seek frame presents it still describes the pre-seek timeline. On a + * backward seek that stale (higher) clock reads freshly banked post-target audio + * as long-stale and the serve trims it away — eating the first second of audio + * after video resumes. INT64_MIN is the serve's hold state (a stream with video + * holds audio until the clock exists), so post-seek audio banks through the + * settle and releases in sync with the first presented frame. Audio-only stays + * ungated: its offset never leaves INT64_MIN in the first place. */ + __atomic_store_n(&d->audClockOffsetUs, INT64_MIN, __ATOMIC_RELAXED); /* Latch the target before bumping the generation so any leg that sees the new * generation reads the matching target. */ __atomic_store_n(&d->seekTargetUs, target_us, __ATOMIC_RELEASE); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c index 43fe516dd..a163b68de 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c @@ -338,6 +338,18 @@ static void sink_video_format(void* user, basis_codec_t codec, const uint8_t* ed static void sink_video_au(void* user, const uint8_t* au, int len, int64_t pts, int64_t dts, int key) { basis_media_engine_t* e = (basis_media_engine_t*)user; if (!e->running) return; + /* Drop video a demuxer emits after a seek is posted but before the main leg + * takes it (the same read-buffer-granularity window the audio drop gate + * covers). These tail AUs are mid-GOP leftovers that post-date the decoder's + * seek flush: they can't decode correctly without their references, some + * hardware decoders emit them anyway against stale reference memory (a + * flash of the pre-seek picture), and — because their PTS can sit past the + * seek target — the first of them would end the decoder's preroll run-up + * early and let the whole run-up render. Video always rides the main leg + * (a split source's audio leg never submits video). HLS is excluded for + * the same reason as audio: it repositions at the BASIS_READ_REPOSITION + * boundary and seek_taken is not its signal. */ + if (!e->active_hls && e->seek_seq != e->seek_taken_main) return; /* Pace on the decode timestamp: gating on pts would sleep out a composition * offset the decoder still needs the AU inside of, and starve the other * track's earlier samples queued behind this one on the demux thread. */ @@ -395,6 +407,14 @@ static void sink_audio_frame(void* user, const uint8_t* data, int len, int64_t p if (e->url_audio[0] || !e->video_format_seen) pace_gate(e, pts); /* paced mode: hold until ~real time; no-op otherwise */ if (!e->running) return; + /* Re-check the pre-seek drop after the pace hold: pace_gate parks this thread + * for up to the pace lead, so a seek posted while this frame slept would + * otherwise let it through with its pre-seek PTS. Submitted, it would trigger + * the decoder's seek flush early — the post-flush timeline re-anchor would + * measure against its stale PTS — and it would sit in the flushed ring as a + * stale front chunk. */ + taken = e->url_audio[0] ? e->seek_taken_audio : e->seek_taken_main; + if (!e->active_hls && e->seek_seq != taken) return; e->audio_frame_count++; mutex_lock(&e->submit_lock); basis_decoder_submit_audio(e->decoder, data, len, pts); @@ -426,7 +446,14 @@ static void sink_transport(void* user, const char* t) { e->transport[sizeof(e->transport) - 1] = 0; mutex_unlock(&e->lock); } -static void sink_eos(void* user) { basis_engine_set_state((basis_media_engine_t*)user, BASIS_MEDIA_STATE_ENDED); } +/* Live end-of-stream ends now. A paced (VOD) source's delivery runs ahead of + * presentation — the pace lead, the audio serve cushion, and any post-seek + * settle skew are all still banked when the demuxer finishes — so its ENDED + * is raised by demux_body after the presentation drain, not here. */ +static void sink_eos(void* user) { + basis_media_engine_t* e = (basis_media_engine_t*)user; + if (!e->paced) basis_engine_set_state(e, BASIS_MEDIA_STATE_ENDED); +} static void sink_duration(void* user, int64_t us) { basis_media_engine_t* e = (basis_media_engine_t*)user; if (us > 0) e->duration_us = us; } /* A raised error is fatal to the current demux run: the reconnect loop already * treats an error state as non-retryable, so stopping here makes the protocol @@ -441,12 +468,20 @@ static int take_seek_common(basis_media_engine_t* e, volatile long* taken, int64 mutex_lock(&e->lock); long seq = e->seek_seq; int64_t us = e->seek_target_us; - /* Re-anchor delivery pacing: only post-seek samples flow on this leg from - * here, and against the old anchor they'd read as far-future (a forward - * seek stalls the demux thread for the jump distance) or as late (a - * backward seek floods through unpaced and fast-forwards back). The next - * paced sample re-establishes base/wall from its own timestamp. */ - if (*taken != seq) e->pace_started = 0; + /* Re-anchor delivery pacing at the seek TARGET, not at whatever sample + * arrives next. A container seek repositions to the sync point at or + * before the target — with a sparse-keyframe file that can be tens of + * seconds of run-up — and anchoring on the first delivered sample would + * pace that whole preroll at 1x (a silent, position-pinned crawl to the + * target). Against a target anchor the preroll reads as late and flows at + * decode speed while everything from the target onwards paces at 1x. The + * decoders drop decoded video frames short of the target (they exist only + * as references), so the preroll is never shown. */ + if (*taken != seq) { + e->pace_wall0_us = now_us(); + e->pace_base_pts = us; + e->pace_started = 1; + } mutex_unlock(&e->lock); if (*taken == seq) return 0; *taken = seq; @@ -1073,8 +1108,45 @@ static void demux_body(basis_media_engine_t* e) { /* Paced (VOD) sources are finite and play once: a clean run end is EOF, not * a live drop to reconnect through. Looping would replay from PTS 0 while the * paced clock is at the old edge — every frame would read "behind" the clock - * and flood in ungated (fast-forward). Stop instead. */ - if (e->paced) { basis_engine_set_state(e, BASIS_MEDIA_STATE_ENDED); break; } + * and flood in ungated (fast-forward). Stop instead — but let presentation + * drain first: delivery runs ahead by the pace lead plus the audio serve + * cushion (and any post-seek settle skew), so several seconds can still be + * banked when the demuxer finishes. ENDED fires once the reported position + * has stopped advancing for a beat; paused time doesn't count as idle. */ + if (e->paced) { + /* Flush the video decoder's reorder tail into the ring first — + * nothing else ever tells it the stream is over, and what it + * retains is the end of the file (seconds, at low frame rates). */ + if (e->decoder) { + mutex_lock(&e->submit_lock); + basis_decoder_notify_end_of_stream(e->decoder); + mutex_unlock(&e->submit_lock); + } + /* Presentation is drained when the decoder holds nothing more to + * show or serve AND the reported position has settled — a stall + * alone is not enough, since a variable-frame-rate tail can hold + * the position flat for seconds with frames still queued. The + * absolute cap is the escape hatch for a consumer that never + * presents (a headless probe) or a wedged renderer. Paused time + * counts toward neither clock. */ + int64_t last_pos = -1; + int idle_ms = 0, waited_ms = 0; + while (e->running) { + if (e->paused) { idle_ms = 0; sleep_interruptible(e, 50); continue; } + int pending = e->decoder ? basis_decoder_presentation_pending(e->decoder) : 0; + int64_t pos = e->decoder ? basis_decoder_get_position_us(e->decoder) : -1; + if (pos != last_pos) { last_pos = pos; idle_ms = 0; } + else idle_ms += 50; + if (!pending && idle_ms >= 700) break; + waited_ms += 50; + if (waited_ms >= 10000) break; + sleep_interruptible(e, 50); + } + /* A stop/close that cleared `running` mid-drain must not read as the + * content finishing: ENDED reaches OnEnded consumers (playlists). */ + if (e->running) basis_engine_set_state(e, BASIS_MEDIA_STATE_ENDED); + break; + } long au_after = e->video_au_count + e->audio_frame_count; long delta = au_after - au_before; diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h index edb1744a1..f7996308b 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h +++ b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h @@ -180,6 +180,18 @@ int basis_decoder_get_video_size(basis_decoder_t* dec, int* out_w, int* out * video processor can't mirror on this GPU; Vulkan always normalizes to 0. */ int basis_decoder_get_frame_origin(basis_decoder_t* dec); int64_t basis_decoder_get_position_us(basis_decoder_t* dec); +/* Delivery has ended: flush the video decoder's reorder tail into the frame + * ring (nothing else ever tells it the stream is over, and the retained + * frames are the last seconds of the file — large at low frame rates). + * Video only: the audio decoder's latency is a single frame and a split + * source's audio leg may still be submitting. Call on the video-submit + * (demux) thread under the engine's submit lock. */ +void basis_decoder_notify_end_of_stream(basis_decoder_t* dec); +/* Non-zero while decoded frames are still banked for presentation or decoded + * audio is still unserved — the end-of-stream drain waits on this rather than + * inferring quiescence from a position stall (which a variable-frame-rate + * tail can legitimately hold flat for seconds). */ +int basis_decoder_presentation_pending(basis_decoder_t* dec); int basis_decoder_get_audio_format(basis_decoder_t* dec, int* out_rate, int* out_channels); int basis_decoder_read_audio(basis_decoder_t* dec, float* out, int max_floats); /* audio thread */ int basis_decoder_get_debug(basis_decoder_t* dec, char* buf, int size); /* diagnostics */ diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/other/basis_decoder_stub.c b/Basis/Packages/com.basis.mediaplayer/Native~/other/basis_decoder_stub.c index f7a7a1625..2c96b9f2e 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/other/basis_decoder_stub.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/other/basis_decoder_stub.c @@ -43,6 +43,8 @@ uint64_t basis_decoder_get_frame_counter(basis_decoder_t* d) { (void)d; return 0 int basis_decoder_get_video_size(basis_decoder_t* d, int* w, int* h) { (void)d;(void)w;(void)h; return -1; } int basis_decoder_get_frame_origin(basis_decoder_t* d) { (void)d; return 0; } int64_t basis_decoder_get_position_us(basis_decoder_t* d) { (void)d; return -1; } +void basis_decoder_notify_end_of_stream(basis_decoder_t* d) { (void)d; } +int basis_decoder_presentation_pending(basis_decoder_t* d) { (void)d; return 0; } int basis_decoder_get_audio_format(basis_decoder_t* d, int* r, int* c) { (void)d;(void)r;(void)c; return -1; } int basis_decoder_read_audio(basis_decoder_t* d, float* o, int m) { (void)d;(void)o;(void)m; return 0; } int basis_decoder_get_debug(basis_decoder_t* d, char* buf, int size) { (void)d; if (buf && size > 0) buf[0] = 0; return 0; } diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c index 8c7f4807f..eb0d94161 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c @@ -144,7 +144,16 @@ typedef struct basis_hls { hls_thread_t thread; int thread_started; volatile int stop; - volatile int producer_done; + volatile int producer_done; /* the producer thread has actually exited (stop, + * policy block, reload exhaustion) — the only + * state that rejects a seek */ + volatile int vod_idle; /* VOD fully fetched and the producer is parked, + * alive, waiting for a seek to revive it; with a + * drained ring and no seek in flight the reader + * reports end-of-stream. Set in the endlist + * branch and cleared in the seek flush, both + * under `lock`, so the reader can never see an + * idle mark alongside a fresh flush generation */ /* Delivery is paced by the engine (pace_gate, by AU timestamp), not here. */ } basis_hls_t; @@ -595,8 +604,11 @@ static void hls_producer(basis_hls_t* h) { /* Publish the flush for the snapshot generation under the same lock * as the ring clear, so a consumer that acquires it sees an emptied * ring and the matched generation together; its next serve is - * guaranteed post-seek. */ + * guaranteed post-seek. Leaving idle in the same critical section + * keeps the reader from ever pairing an emptied ring with a stale + * end-of-stream verdict while the target segment is still fetching. */ h->flush_gen = seek_g; + h->vod_idle = 0; hls_mutex_unlock(&h->lock); } if (!h->seg_ctx) { @@ -624,16 +636,21 @@ static void hls_producer(basis_hls_t* h) { } h->empty_reloads = 0; } else if (h->endlist_seen) { - /* VOD exhausted. Arbitrate against a late seek under the lock: - * either honour a pending request (loop back) or mark the - * producer done so basis_hls_request_seek rejects further - * seeks. The two can't interleave, so no request is lost and - * the reader never withholds on a flush that never comes. */ + /* Unseekable VOD (fMP4: no TS segment list, request_seek + * rejects it) has nothing to park for — exit normally. */ + if (h->vod_count == 0) break; + /* VOD exhausted — park, don't exit. A backward seek into the + * tail must still work for as long as the source is open, so + * the thread idles here and the top-of-loop seek take revives + * it. Arbitrate against a pending request under the lock so + * the two can't interleave: either loop back to honour it, or + * publish idle for the reader's end-of-stream verdict. */ hls_mutex_lock(&h->lock); if (h->seek_pending) { hls_mutex_unlock(&h->lock); continue; } - h->producer_done = 1; + h->vod_idle = 1; hls_mutex_unlock(&h->lock); - break; /* VOD / stream finished */ + hls_sleep_ms(10); + continue; } else { int r = reload_and_enqueue(h); if (r > 0) { h->empty_reloads = 0; } @@ -822,8 +839,9 @@ int basis_hls_read(void* ctx, uint8_t* buf, int len) { /* Seek in flight: the ring still holds pre-seek bytes until the producer * flushes and requeues at the target. Withhold them, since handing them * to the demuxer would let a stale AU re-anchor pacing to the old timeline. - * If the producer has already exited (VOD genuinely ended) without - * honouring the seek, stop waiting for a flush that will never come. */ + * producer_done here means the thread actually exited (stop / policy + * block / reload exhaustion) — a parked VOD producer still honours the + * request — so only then stop waiting for a flush that will never come. */ if (h->seek_gen != h->flush_gen) { int done = h->producer_done; hls_mutex_unlock(&h->lock); @@ -850,9 +868,13 @@ int basis_hls_read(void* ctx, uint8_t* buf, int len) { hls_mutex_unlock(&h->lock); return take; } - int done = h->producer_done; + /* End of stream: the ring is drained and either the producer exited or a + * fully-fetched VOD is parked with no seek in flight (this branch is only + * reachable with the generations settled, so a pending seek can't race + * the verdict — request_seek bumps the generation under this lock). */ + int done = h->producer_done || h->vod_idle; hls_mutex_unlock(&h->lock); - if (done) return 0; /* producer finished and ring drained -> end of stream */ + if (done) return 0; hls_sleep_ms(2); /* ring empty: wait for the producer to buffer more */ } @@ -876,12 +898,13 @@ int basis_hls_can_seek(void* ctx) { int basis_hls_request_seek(void* ctx, long long target_ms) { basis_hls_t* h = (basis_hls_t*)ctx; if (!h || h->vod_count <= 0 || target_ms < 0) return -1; /* vod_count is fixed at open */ - /* Accept the seek atomically with the producer_done check so a request can't - * be lost against the producer exiting on end-of-stream: the endlist path - * sets producer_done under this same lock while verifying no seek is pending, - * so exactly one of the two wins. Publish {target, generation, pending} - * together; the generation runs ahead of flush_gen until the producer - * finishes flushing, during which the consumer withholds the pre-seek ring. */ + /* Reject only when the producer thread has actually exited — a fully-fetched + * VOD parks its producer instead, precisely so a seek into the tail (or after + * playout drained the ring) still repositions. Accept atomically with that + * check so a request can't be lost against a concurrent exit; publish + * {target, generation, pending} together, and the generation runs ahead of + * flush_gen until the producer finishes flushing, during which the consumer + * withholds the pre-seek ring. */ hls_mutex_lock(&h->lock); if (h->producer_done) { hls_mutex_unlock(&h->lock); return -1; } h->seek_target_ms = (long)target_ms; diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp b/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp index 5c73c3739..23283bd09 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp +++ b/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp @@ -380,6 +380,32 @@ struct basis_decoder { * vdec + dropped pre-seek frames; the render leg * holds until it matches so it neither anchors to a * stale frame nor races the producer's ring clear */ + int64_t vPrerollCutUs = INT64_MIN; /* video-submit thread only: after a seek, decoded + * frames short of this are the keyframe run-up to + * the target — reference-only, never banked. Set + * from seekTargetUs at the seek flush, cleared by + * the first frame at or past it. */ + int vAwaitKey = 0; /* video-submit thread only: set at the seek flush, + * cleared by the first OUTPUT of the first keyframe + * submitted after it (matched by PTS via + * vAwaitKeyPts). Output produced before that is + * post-flush mid-GOP input decoded against stale + * references (the HLS path can still emit a pre-seek + * tail AU the engine's seek_taken gate doesn't + * cover) — never banked, and it must not end the + * preroll run-up either. Clearing at the keyframe's + * SUBMISSION is not enough: the MFT's reorder + * pipeline can emit a tail AU's garbage frame after + * it, with a pre-seek PTS past the target. */ + int64_t vAwaitKeyPts = INT64_MIN; /* video-submit thread only: PTS of that keyframe; + * INT64_MIN until it is submitted */ + int vAwaitDrained = 0; /* video-submit thread only: outputs drained since + * the seek flush; bounds the wait so a dropped or + * re-stamped keyframe output — or a run whose + * post-seek AUs are never flagged as keyframes, + * which would otherwise never latch vAwaitKeyPts — + * can't hold the gate (and video) shut until the + * next seek */ }; /* ---- D3D / MF helpers --------------------------------------------------- */ @@ -966,7 +992,31 @@ static void drain_video(basis_decoder* d) { UINT subIndex = 0; dxgi->GetResource(__uuidof(ID3D11Texture2D), (void**)&tex); dxgi->GetSubresourceIndex(&subIndex); - if (tex) { video_process_to_shared(d, tex, subIndex, d->lastPtsUs); tex->Release(); } + if (tex) { + /* Post-seek preroll (keyframe run-up short of the target): + * decoded so later frames have their references, never shown. + * Output is display-order, so the first frame at or past the + * target ends the run-up for good — but only once the + * post-flush keyframe's own output has emerged: output before + * that is post-flush mid-GOP garbage whose PTS may sit past + * the target, and it must neither show nor end the run-up. + * The PTS match is the designed clear; the drain bound is a + * backstop, set well past any plausible garbage-tail length, + * so a dropped or re-stamped keyframe output degrades to at + * worst one stale frame instead of wedging video. */ + if (d->vAwaitKey && + ((d->vAwaitKeyPts != INT64_MIN && d->lastPtsUs == d->vAwaitKeyPts) || + ++d->vAwaitDrained > 16)) + d->vAwaitKey = 0; + if (d->vAwaitKey || + (d->vPrerollCutUs != INT64_MIN && d->lastPtsUs < d->vPrerollCutUs)) { + /* skip banking */ + } else { + d->vPrerollCutUs = INT64_MIN; + video_process_to_shared(d, tex, subIndex, d->lastPtsUs); + } + tex->Release(); + } dxgi->Release(); } else { /* Only DXGI-backed samples reach the video processor. A @@ -1492,7 +1542,6 @@ static IMFSample* make_input_sample(const uint8_t* data, int len, int64_t pts_us * each output under its own lock. Keep video-path and audio-path state disjoint to preserve * this; if that ever changes, serialise submission through a decoder mutex. */ extern "C" int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* annexb, int len, int64_t pts_us, int key) { - (void)key; /* Bound len here, before the AV1 configOBU concatenation below adds to it — so * the total can't overflow int or drive an oversized allocation. */ if (!d || !d->vdec || !annexb || len <= 0 || len > BASIS_MAX_INPUT_SAMPLE) return -1; @@ -1506,12 +1555,17 @@ extern "C" int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* ann d->videoSeekGen = svg; d->vdec->ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0); for (int i = 0; i < basis_decoder::RING; ++i) d->ringPts[i] = INT64_MIN; + d->vPrerollCutUs = InterlockedCompareExchange64(&d->seekTargetUs, 0, 0); + d->vAwaitKey = 1; + d->vAwaitKeyPts = INT64_MIN; + d->vAwaitDrained = 0; /* The ring is this (demux) thread's to clear — do it here only, then publish * the generation so the render leg knows the pre-seek frames are gone. That * keeps a single writer of the ring on seek and stops the render leg from * clearing frames this thread may already have repopulated. */ InterlockedExchange(&d->videoSeekAck, svg); } + if (key && d->vAwaitKey && d->vAwaitKeyPts == INT64_MIN) d->vAwaitKeyPts = pts_us; IMFSample* s; bool carried_config = false; if (d->vConfigObusLen > 0) { @@ -2092,6 +2146,44 @@ extern "C" int basis_decoder_get_video_size(basis_decoder_t* d, int* w, int* h) } extern "C" int basis_decoder_get_frame_origin(basis_decoder_t* d) { return d ? (int)d->frameTopLeft : 0; } +extern "C" void basis_decoder_notify_end_of_stream(basis_decoder_t* d) { + if (!d || !d->vdec) return; + /* Caller is the video-submit (demux) thread, which owns vdec and the ring — + * same ownership as submit_video, so drain_video is safe here. The MFT is + * synchronous: after DRAIN, ProcessOutput hands over every retained frame, + * and drain_video's until-NEED_MORE_INPUT loop is that documented pattern. + * Best-effort by design: if either message fails, the tail stays inside + * the MFT, presentation_pending never sees it, and the core's drain-wait + * ends on its idle cap — the frames are unrecoverable either way, so + * there is nothing a propagated HRESULT could change. */ + d->vdec->ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0); + d->vdec->ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0); + drain_video(d); +} + +extern "C" int basis_decoder_presentation_pending(basis_decoder_t* d) { + if (!d) return 0; + int pending = 0; + EnterCriticalSection(&d->presentLock); + /* Compare against lastPresentedPts, not presentedPosUs: a seek snaps the + * latter to the target before anything presents, so a banked frame whose + * PTS lands exactly on the target would read as already shown and the + * EOS drain could raise ENDED without it. lastPresentedPts stays + * INT64_MIN until a frame genuinely presents, and the final frame's + * lingering ring slot equals it (not >), so a finished play-out still + * reads as drained. */ + int64_t presented = d->lastPresentedPts; + for (int i = 0; i < basis_decoder::RING; ++i) + if (d->ringPts[i] != INT64_MIN && d->ringPts[i] > presented) { pending = 1; break; } + LeaveCriticalSection(&d->presentLock); + if (!pending) { + EnterCriticalSection(&d->pcm.cs); + pending = d->pcm.fill() > 0; + LeaveCriticalSection(&d->pcm.cs); + } + return pending; +} + extern "C" void basis_decoder_seek(basis_decoder_t* d, int64_t target_us) { if (!d) return; /* Record the pre-seek audio front before the flush clears it, so the audio-only @@ -2107,6 +2199,15 @@ extern "C" void basis_decoder_seek(basis_decoder_t* d, int64_t target_us) { * safe from this (caller) thread; the codec-state reset stays on the submit * thread where the MFT/Opus decoder is owned. */ d->pcm.flush(); + /* Invalidate the audio serve clock: it re-derives from presents, and until the + * first post-seek frame presents it still describes the pre-seek timeline. On a + * backward seek that stale (higher) clock reads freshly banked post-target audio + * as long-stale and the serve trims it away — eating the first second of audio + * after video resumes. INT64_MIN is the serve's hold state (a stream with video + * holds audio until the clock exists), so post-seek audio banks through the + * settle and releases in sync with the first presented frame. Audio-only stays + * ungated: its offset never leaves INT64_MIN in the first place. */ + InterlockedExchange64(&d->audClockOffsetUs, INT64_MIN); /* Latch target before bumping the generation so any leg that observes the new * generation reads the matching target. */ InterlockedExchange64(&d->seekTargetUs, target_us); diff --git a/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so b/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so index 6ff52336e..fb31374f0 100644 Binary files a/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so and b/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so differ diff --git a/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll b/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll index 1c394137e..d230a2c4c 100644 Binary files a/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll and b/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll differ diff --git a/Basis/Packages/com.basis.mediaplayer/TESTING.md b/Basis/Packages/com.basis.mediaplayer/TESTING.md index 3c127d83b..4b374cdaf 100644 --- a/Basis/Packages/com.basis.mediaplayer/TESTING.md +++ b/Basis/Packages/com.basis.mediaplayer/TESTING.md @@ -224,15 +224,28 @@ different URL mid-play. No stale frames, no orphaned audio, position resets corr seek-then-pause shows the sought frame. The byte-source ranged refetch that backs a seek now runs on **Android** too (JNI `HttpsURLConnection`), not just Windows — run the same slider checks on a Quest against a range/`206` VOD host (`https://`), watching `adb logcat` for a clean -reposition (no decoder error, playback resumes at the target). +reposition (no decoder error, playback resumes at the target). A container seek repositions to +the sync point at or before the target and the run-up from there is decoded but never shown, so +playback lands **at the requested position** — never visibly replaying from the keyframe, and +never crawling the gap at 1x. The adversarial case is a **sparse-keyframe file** (tens of +seconds per GOP, e.g. a low-motion screen capture): a mid-GOP seek there should recover after a +short silent pause that scales with decode speed, not with the distance seeked. **Seek (HLS-TS VOD)** — on the Mux master (`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`), seek both directions and confirm playback resumes **paced at 1x from the target**: a forward seek must not freeze for the jump distance, and a backward seek must not fast-forward through -the intervening segments back to the pre-seek position. The segment producer repositions and -the demux leg re-anchors delivery pacing at the flushed boundary, so a mis-anchored pace clock -(stall forward / flood backward) is the failure to watch for. Shared clock, so check both the -Editor (Windows) and Quest. +the intervening segments back to the pre-seek position. The segment producer repositions at +segment granularity but the landing is **target-exact**: the run-up from the segment boundary +to the target is decoded and discarded, so playback must resume at the requested position, not +the start of the containing segment. A mis-anchored pace clock (stall forward / flood backward) +is the failure to watch for. Also seek **from the tail**: once the fetcher has downloaded every +remaining segment (the last buffer's worth of the stream, so roughly the final ten seconds of a +short VOD) it parks rather than exits, and a backward seek from there must still reposition — +a bar that flashes the target and snaps forward means the parked-fetcher revival broke. Playing +through to the end must present the tail before ENDED is raised: the position walks all the way +to the true duration and the final content is actually shown and heard — ENDED firing early +while banked audio or video is discarded is the failure. Shared clock, so check both the Editor +(Windows) and Quest. **Seek (integrated fMP4)** — on a self-contained fragmented MP4 (moof/mdat fragments indexed by a top-level `sidx`) served from a range/`206` host. Produce one from a CC clip: