diff --git a/Checks/local-seek.mm b/Checks/local-seek.mm new file mode 100644 index 0000000..624e26a --- /dev/null +++ b/Checks/local-seek.mm @@ -0,0 +1,68 @@ +// Exercise the real local seek/decode/presentation loop without worker races. +#include "../Sources/Core/player.cpp" +#include +#include + +static void require(bool condition, const char *message) { + if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); } +} +static std::vector pixels(const AVFrame *frame) { + const auto format = static_cast(frame->format); + const int size = av_image_get_buffer_size(format, frame->width, frame->height, 1); + require(size > 0, "preview has readable pixels"); + std::vector bytes(size); + require(av_image_copy_to_buffer(bytes.data(), size, frame->data, frame->linesize, + format, frame->width, frame->height, 1) == size, "copy preview pixels"); + return bytes; +} + + +int main(int argc, char **argv) { + require(argc > 1, "provide local media fixtures"); + for (int i = 1; i < argc; ++i) { + av_log_set_level(AV_LOG_ERROR); + Player player; + player.shutdown(); + player.stopping.store(false); + player.activeEpoch = player.requestedEpoch.load(); + player.open(Command{Action::Open, argv[i]}); + require(player.tracks[1].codec && player.canSeek, "seekable local video"); + player.output.set_volume(0); + for (bool playing : {false, true}) { + for (double fraction : {.01, .79, .21, .59}) { + const double target = player.duration * fraction; + // Full reference decoding supplies an independent expected picture, + // including disposable B-frames at or immediately after the target. + player.seek(target, false); + VideoFrame expected{}; + const auto referenceStart = Clock::now(); + while (!expected.frame && Clock::now() - referenceStart < std::chrono::seconds(5)) { + player.decodeOne(true); + while (!player.video.empty()) { + auto frame = std::move(player.video.front()); + player.videoBytes -= frame.bytes; + player.video.pop_front(); + if (frame.pts + sampleTime * .5 >= target) { + expected = std::move(frame); + break; + } + } + } + require(bool(expected.frame), "full decode supplies reference picture"); + const auto start = Clock::now(); + player.seek(target, playing); + while ((!player.presented.frame || player.presented.pts < target - .002) && + Clock::now() - start < std::chrono::seconds(5)) + player.tick(); + const double elapsed = std::chrono::duration(Clock::now() - start).count(); + std::printf("local-seek fixture=%s playing=%d target=%.3f preview=%.3f latency_ms=%.3f\n", + argv[i], playing, target, player.presented.pts, elapsed); + require(player.presented.frame && player.presented.pts == expected.pts && + pixels(player.presented.frame.get()) == pixels(expected.frame.get()), + "seek presents the exact fully decoded picture, including B-frame targets"); + require(player.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED), + "seek preserves playback intent"); + } + } + } +} diff --git a/Makefile b/Makefile index ac7eda5..cce55ad 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,18 @@ $(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sou check-seek-queue: $(BUILD_DIR)/seek-queue-check $(BUILD_DIR)/seek-queue-check -check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue +$(BUILD_DIR)/local-seek-check: Checks/local-seek.mm Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies + $(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@ + +$(BUILD_DIR)/fixtures/local-seek.mp4: Makefile | check-dependencies + mkdir -p $(BUILD_DIR)/fixtures + ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=1920x1080:rate=30:duration=12 -f lavfi -i sine=frequency=440:sample_rate=48000:duration=12 -c:v libx264 -preset ultrafast -threads 2 -bf 2 -g 300 -keyint_min 300 -sc_threshold 0 -c:a aac $@ + +.PHONY: check-local-seek +check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES) + $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES) + +check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures check-fixtures: | check-dependencies diff --git a/README.md b/README.md index efc9775..8ceccce 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Settings load once at startup. Menu changes are session-only and never write the ## Limits - SDR output only; PQ/HLG HDR is rejected. No stream playlists, playlist-file import, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. The session-only local-file queue and external resolved-source WebVTT subtitles are supported. -- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media. +- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Local seeks remain frame-accurate: disposable non-reference pictures strictly before the target are skipped, but reference pictures still require decoding. Seeking and backward frame stepping can remain slow on long-GOP or unindexed media. - Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed. - Audio-device changes during playback are not handled. @@ -169,6 +169,8 @@ The full suite requires a graphical macOS session and a working audio device; it The startup checks verify that playing video remains attached, unhidden and sized to its native drag surface, including with the queue open; playback-state checks alone do not establish renderer visibility. +`make check-local-seek` measures paused/playing local seek-to-picture latency and compares the resulting timestamps and pixels against full decoding, including B-frame targets. It covers a 1080p H.264 long-GOP file with audio and the six hardware/software decoder fixtures; timing is diagnostic, not a machine-dependent pass threshold. + Queue-open resize checks require positive native resize increments and exercise continuous horizontal/vertical resize-delegate sequences, including reversals, to preserve video aspect without snap-back. The driving axis is fixed for each live resize gesture so aspect corrections do not feed back into axis selection. Physical edge-drag resizing still needs manual verification. `make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency. diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index 641ba09..30595a2 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -468,6 +468,7 @@ struct Player { for (auto &track : tracks) { if (!track.codec) continue; avcodec_flush_buffers(track.codec); + track.codec->skip_frame = AVDISCARD_DEFAULT; track.drainSent = track.drained = false; track.firstPacket = true; av_packet_unref(track.packet); @@ -1151,6 +1152,17 @@ struct Player { "Preparing video decoder preroll"); track.firstPacket = false; } + if (slot == 1) { + // Preroll needs reference pictures, not disposable pictures before + // the target. Restore full decoding before the target packet and + // for exact frame stepping; never guess when timestamps are absent. + const auto *packet = track.packet; + const bool preroll = !network && !stepping && !previewShown && seekTarget > 0 && + packet->pts != AV_NOPTS_VALUE && + packet->duration > 0 && + (double(packet->pts) + packet->duration) * av_q2d(track.stream()->time_base) - origin < seekTarget; + track.codec->skip_frame = preroll ? AVDISCARD_NONREF : AVDISCARD_DEFAULT; + } const int result = avcodec_send_packet(track.codec, track.packet); if (result != AVERROR(EAGAIN)) { check(result, "Sending media packet to decoder"); diff --git a/TODO.md b/TODO.md index e2dab05..1fdca2b 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,7 @@ - multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified - [x] make sure the `make install` command installs the application (i can't find it in spotlight) - installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified -- [ ] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant +- [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant + - local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) \ No newline at end of file