feat: step to adjacent decoded video frames

This commit is contained in:
2026-10-10 17:49:04 +02:00
parent 310be5aa61
commit a99b0c1168
6 changed files with 311 additions and 29 deletions
+101
View File
@@ -31,12 +31,109 @@ static PlayerState until(Player *player, const std::function<bool(PlayerState)>
std::exit(1); std::exit(1);
} }
static PlayerState frameCompleted(Player *player, uint64_t revision, double pts) {
auto state = until(player, [revision](auto s) {
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
});
if (std::abs(state.position - pts) >= .0001) {
std::fprintf(stderr, "Frame PTS: expected %.4f, got %.4f\n", pts, state.position);
require(false, "step selects exact adjacent presentation timestamp");
}
return state;
}
static PlayerState step(Player *player, int direction, double pts) {
auto revision = player_get_state(player).revision;
require(player_step_frame(player, direction), "frame step accepted");
return frameCompleted(player, revision, pts);
}
static void frameSteps(Player *player, MTKView *view, const std::string &directory) {
// Independent literals from the fixture's integer-millisecond setpts input.
constexpr double pts[] = {0, .1, .2, .3, .4, .65, .9, 1.15, 1.4, 1.5, 1.6, 1.7};
require(player_open(player, (directory + "/frame-step.mp4").c_str()), "VFR open accepted");
until(player, [](auto s) { return !s.seek_pending && s.status == PLAYER_PAUSED; });
require(!player_step_frame(player, 0) && !player_step_frame(player, 2) &&
!player_step_frame(player, -2), "only adjacent frame directions accepted");
step(player, -1, 0); // First boundary also ensures the initial preview is ready.
for (unsigned i = 1; i < sizeof(pts) / sizeof(pts[0]); ++i) {
step(player, 1, pts[i]);
[view draw];
}
step(player, 1, 1.7);
for (int i = 10; i >= 0; --i) step(player, -1, pts[i]);
step(player, -1, 0);
auto revision = player_get_state(player).revision;
require(player_step_frame(player, 1) && player_step_frame(player, 1) &&
player_step_frame(player, 1) && player_step_frame(player, 1),
"repeated queued steps accepted");
frameCompleted(player, revision, .4);
revision = player_get_state(player).revision;
require(player_step_frame(player, 1) && player_step_frame(player, 1) &&
player_step_frame(player, -1), "mixed queued steps accepted");
frameCompleted(player, revision, .65);
revision = player_get_state(player).revision;
require(player_seek(player, .72), "non-keyframe seek accepted");
until(player, [revision](auto s) {
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
});
// Seek previews .9, not the .72 authoritative seek clock and not keyframe zero.
step(player, 1, 1.15);
step(player, -1, .9);
step(player, -1, .65);
revision = player_get_state(player).revision;
require(player_seek(player, .72) && player_step_frame(player, 1) &&
player_step_frame(player, -1), "queued seek and steps accepted");
frameCompleted(player, revision, .9);
step(player, -1, .65);
require(player_play(player), "play before frame step accepted");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .67 && s.position < .8; });
auto frozen = step(player, 1, .9);
[view draw];
for (int i = 0; i < 20; ++i)
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
auto paused = player_get_state(player);
require(paused.status == PLAYER_PAUSED && !paused.seek_pending &&
std::abs(paused.position - frozen.position) < .0001,
"stepping during playback pauses and freezes audio/video timeline");
require(player_play(player), "resume stepped timeline accepted");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.19 && s.position < 1.32; });
require(player_pause(player), "pause resumed timeline accepted");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; });
// Resumed presentation has advanced from .9 to 1.15; no pre-step frame remains.
step(player, -1, .9);
require(player_play(player), "play stepped media through EOF");
until(player, [](auto s) { return s.status == PLAYER_ENDED; });
step(player, 1, 1.7);
step(player, -1, 1.6);
step(player, 1, 1.7);
step(player, 1, 1.7);
require(player_open(player, (directory + "/video.mp4").c_str()), "video-only stepping open");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; });
revision = player_get_state(player).revision;
require(player_seek(player, 3.9), "seek near video-only last frame");
until(player, [revision](auto s) { return !s.seek_pending && s.revision != revision; });
step(player, 1, 95.0 / 24);
require(player_play(player), "resume video-only last frame");
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; });
require(std::abs(ended.position - 4) < .0001, "last stepped frame retains its presentation duration");
step(player, -1, 94.0 / 24);
require(player_close(player), "close stepped media accepted");
until(player, [](auto s) { return s.status == PLAYER_EMPTY; });
std::puts("PASS exact VFR/B-frame steps/queued steps/seek reference/pause/resume/boundaries");
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
@autoreleasepool { @autoreleasepool {
require(argc == 2, "fixture directory argument required"); require(argc == 2, "fixture directory argument required");
[NSApplication sharedApplication]; [NSApplication sharedApplication];
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy); std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
require(bool(player), "player creation"); require(bool(player), "player creation");
require(!player_step_frame(nullptr, 1) && !player_step_frame(player.get(), 1),
"null and empty players reject frame steps");
void *native = player_macos_make_video_view(player.get()); void *native = player_macos_make_video_view(player.get());
require(native != nullptr, "Metal view and shaders initialize"); require(native != nullptr, "Metal view and shaders initialize");
MTKView *view = (__bridge_transfer MTKView *)native; MTKView *view = (__bridge_transfer MTKView *)native;
@@ -56,6 +153,9 @@ int main(int argc, char **argv) {
"audio track selection"); "audio track selection");
double aspect = !opened.has_video ? 0 : std::string(fixture) == "anamorphic.mp4" ? 8.0 / 3 : 16.0 / 9; double aspect = !opened.has_video ? 0 : std::string(fixture) == "anamorphic.mp4" ? 8.0 / 3 : 16.0 / 9;
require(std::abs(opened.video_aspect_ratio - aspect) < .0001, "display aspect includes non-square pixels"); require(std::abs(opened.video_aspect_ratio - aspect) < .0001, "display aspect includes non-square pixels");
if (!opened.has_video)
require(!player_step_frame(player.get(), 1) && !player_step_frame(player.get(), -1),
"audio-only media rejects frame steps");
require(!player_seek(player.get(), NAN), "non-finite seek rejected"); require(!player_seek(player.get(), NAN), "non-finite seek rejected");
require(player_play(player.get()), "play accepted"); require(player_play(player.get()), "play accepted");
until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .35; }); until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .35; });
@@ -98,6 +198,7 @@ int main(int argc, char **argv) {
"close clears media state and pending seeks"); "close clears media state and pending seeks");
std::printf("PASS %s: open/play/pause/seek/end/replay/close\n", fixture); std::printf("PASS %s: open/play/pause/seek/end/replay/close\n", fixture);
} }
frameSteps(player.get(), view, argv[1]);
require(player_open(player.get(), (std::string(argv[1]) + "/hdr.mkv").c_str()), "HDR open accepted"); require(player_open(player.get(), (std::string(argv[1]) + "/hdr.mkv").c_str()), "HDR open accepted");
auto hdr = until(player.get(), [](auto s) { return s.status == PLAYER_ERROR; }, true); auto hdr = until(player.get(), [](auto s) { return s.status == PLAYER_ERROR; }, true);
require(std::string(hdr.error).find("HDR") != std::string::npos, "HDR reports unsupported output"); require(std::string(hdr.error).find("HDR") != std::string::npos, "HDR reports unsupported output");
+2
View File
@@ -41,6 +41,8 @@ check: .build/playback-check check-audio
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 .build/fixtures/anamorphic.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 .build/fixtures/anamorphic.mp4
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav
# Literal frame PTS: 0,.1,.2,.3,.4,.65,.9,1.15,1.4,1.5,1.6,1.7; reordered B-frames across short GOPs.
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=10:duration=1.2 -f lavfi -i sine=frequency=440:sample_rate=44100:duration=2 -vf "settb=1/1000,setpts='if(lt(N,5),N*100,if(lt(N,9),400+(N-4)*250,1400+(N-8)*100))'" -fps_mode vfr -enc_time_base:v 1:1000 -c:v mpeg4 -g 4 -bf 2 -video_track_timescale 1000 -c:a aac .build/fixtures/frame-step.mp4
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv
.build/playback-check .build/fixtures .build/playback-check .build/fixtures
+9 -4
View File
@@ -27,12 +27,15 @@ Opening video fits the window to its display aspect ratio, including non-square
| `K` / `Space` | Toggle playback | | `K` / `Space` | Toggle playback |
| `M` | Toggle mute, restoring the previous audible level | | `M` | Toggle mute, restoring the previous audible level |
| `J` / `L` | Seek backward / forward ten seconds | | `J` / `L` | Seek backward / forward ten seconds |
| `,` / `.` | Step to the previous / next video frame; leave playback paused |
| `F` | Toggle fullscreen | | `F` | Toggle fullscreen |
| `P` | Toggle keep-on-top (windowed mode) | | `P` | Toggle keep-on-top (windowed mode) |
| `O` | Open the file picker to switch files | | `O` | Open the file picker to switch files |
| `Q` | Quit through normal application shutdown | | `Q` | Quit through normal application shutdown |
Single-key shortcuts apply only to the playback window and do not intercept text editing, file-picker input, or Command/Control/Option combinations. Held toggle keys do not repeatedly toggle; held `J`/`L` can continue seeking. `,` / `.` frame stepping and `C` captions are unbound until supported. Single-key shortcuts apply only to the playback window and do not intercept text editing, file-picker input, or Command/Control/Option combinations. Held toggle keys do not repeatedly toggle; held `J`/`L` continue seeking and held `,`/`.` continue stepping. `C` captions remains unbound.
Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking video also toggles fullscreen. - Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking video also toggles fullscreen.
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray. - The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray.
@@ -43,8 +46,8 @@ Only implemented actions have controls; there are no placeholder playlist, subti
## Where to change things ## Where to change things
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `seek_pending` is set synchronously when a seek is accepted and clears when all queued seeks on that timeline finish. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction. - `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `seek_pending` is set synchronously when a seek or frame step is accepted and clears when all queued seeks/steps on that timeline finish. `player_step_frame` accepts only `-1` or `+1`, requires ready video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction.
- `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming. - `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, exact frame stepping, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming.
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
- `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. - `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration.
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
@@ -55,7 +58,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti
make check make check
``` ```
Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, exact bidirectional VFR/B-frame stepping across keyframes, queued/mixed steps, seek-to-step transitions, first/last-frame boundaries, stepping pause/resume, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test.
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. `make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook.
@@ -69,4 +72,6 @@ Visual correctness, perceived A/V synchronization, and control usability still n
Local regular files, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, rotation/deinterlacing, or network playback yet. Mid-track audio format changes report an error. Local regular files, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, rotation/deinterlacing, or network playback yet. Mid-track audio format changes report an error.
Frame stepping re-decodes from an earlier keyframe and rebuilds audio at the selected timestamp. Long-GOP or unindexed files can therefore step slowly; frames are not cached across the entire file. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent.
Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet. Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet.
+14
View File
@@ -52,6 +52,7 @@ final class PlayerModel: ObservableObject {
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
} }
var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 } var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && !isSeeking }
var canOpen: Bool { handle != nil && !isBusy } var canOpen: Bool { handle != nil && !isBusy }
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY } var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR } var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
@@ -155,6 +156,14 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
} }
func stepFrame(_ direction: Int32) {
guard canStepFrame, let handle else { return }
let accepted = player_step_frame(handle, direction)
if accepted { seekPending = true }
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
}
func setVolume(_ value: Double) { func setVolume(_ value: Double) {
guard let handle else { return } guard let handle else { return }
if player_set_volume(handle, Float(value)) { if player_set_volume(handle, Float(value)) {
@@ -697,6 +706,8 @@ private struct WindowChrome: NSViewRepresentable {
switch key { switch key {
case "j": model.seekRelative(-10) case "j": model.seekRelative(-10)
case "l": model.seekRelative(10) case "l": model.seekRelative(10)
case ",": model.stepFrame(-1)
case ".": model.stepFrame(1)
case "k", " ": case "k", " ":
if !event.isARepeat { model.togglePlayback() } if !event.isARepeat { model.togglePlayback() }
case "m": case "m":
@@ -825,6 +836,9 @@ struct PlayerApp: App {
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek) Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek) Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
Divider() Divider()
Button("Previous Frame") { model.stepFrame(-1) }.disabled(!model.canStepFrame)
Button("Next Frame") { model.stepFrame(1) }.disabled(!model.canStepFrame)
Divider()
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) } Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume) .keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) } Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
+181 -24
View File
@@ -46,7 +46,9 @@ using Frame = std::unique_ptr<AVFrame, FrameDeleter>;
struct VideoFrame { struct VideoFrame {
Frame frame; Frame frame;
double pts; double pts;
double end;
std::size_t bytes; std::size_t bytes;
uint64_t timestampOccurrence = 0;
}; };
struct Track { struct Track {
AVCodecContext *codec = nullptr; AVCodecContext *codec = nullptr;
@@ -54,18 +56,26 @@ struct Track {
bool drainSent = false; bool drainSent = false;
bool drained = false; bool drained = false;
double nextPts = 0; double nextPts = 0;
int64_t countedTimestamp = AV_NOPTS_VALUE;
uint64_t timestampOccurrence = 0;
}; };
enum class Action { Open, Play, Pause, Seek, Close }; enum class Action { Open, Play, Pause, Seek, Step, Close };
struct Command { struct Command {
Action action; Action action;
std::string path; std::string path;
double seconds = 0; double seconds = 0;
int direction = 0;
uint64_t epoch = 0; uint64_t epoch = 0;
}; };
void copyError(char *destination, const char *message) noexcept { void copyError(char *destination, const char *message) noexcept {
std::snprintf(destination, 512, "%s", message ? message : "Unknown playback error"); std::snprintf(destination, 512, "%s", message ? message : "Unknown playback error");
} }
int64_t presentationTimestamp(const AVFrame *frame) {
return frame->best_effort_timestamp != AV_NOPTS_VALUE ?
frame->best_effort_timestamp : frame->pts;
}
} }
struct Player { struct Player {
@@ -74,6 +84,7 @@ struct Player {
std::condition_variable wake; std::condition_variable wake;
std::deque<Command> commands; std::deque<Command> commands;
PlayerState state{}; PlayerState state{};
uint64_t publishedEpoch = 0;
std::array<char, 512> requestedError{}; std::array<char, 512> requestedError{};
bool errorRequested = false; bool errorRequested = false;
uint64_t errorEpoch = 0; uint64_t errorEpoch = 0;
@@ -96,6 +107,9 @@ struct Player {
int receiving = -1; int receiving = -1;
std::deque<VideoFrame> video; std::deque<VideoFrame> video;
std::size_t videoBytes = 0; std::size_t videoBytes = 0;
VideoFrame presented{};
int64_t skipPresentedTimestamp = AV_NOPTS_VALUE;
uint64_t skipPresentedOccurrence = 0;
std::vector<float> pcm; std::vector<float> pcm;
std::size_t pcmFrames = 0; std::size_t pcmFrames = 0;
std::size_t pcmOffset = 0; std::size_t pcmOffset = 0;
@@ -127,6 +141,10 @@ struct Player {
std::lock_guard<std::mutex> lock(mutex); std::lock_guard<std::mutex> lock(mutex);
if (stopping.load(std::memory_order_relaxed) || commands.size() >= commandLimit) if (stopping.load(std::memory_order_relaxed) || commands.size() >= commandLimit)
return false; return false;
if (command.action == Action::Step &&
(!state.has_video || publishedEpoch != requestedEpoch.load(std::memory_order_relaxed) ||
(state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED)))
return false;
const bool newTimeline = command.action == Action::Open || command.action == Action::Close; const bool newTimeline = command.action == Action::Open || command.action == Action::Close;
command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0); command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0);
commands.push_back(std::move(command)); commands.push_back(std::move(command));
@@ -137,7 +155,8 @@ struct Player {
state.status = PLAYER_OPENING; state.status = PLAYER_OPENING;
state.revision = revision; state.revision = revision;
} }
if (commands.back().action == Action::Seek) state.seek_pending = true; if (commands.back().action == Action::Seek || commands.back().action == Action::Step)
state.seek_pending = true;
wake.notify_one(); wake.notify_one();
return true; return true;
} }
@@ -175,14 +194,16 @@ struct Player {
void publish() { void publish() {
std::lock_guard<std::mutex> lock(mutex); std::lock_guard<std::mutex> lock(mutex);
if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return; if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return;
publishedEpoch = activeEpoch;
state.status = status; state.status = status;
state.position = position; state.position = position;
state.duration = duration; state.duration = duration;
state.has_audio = tracks[0].codec != nullptr; state.has_audio = tracks[0].codec != nullptr;
state.has_video = tracks[1].codec != nullptr; state.has_video = tracks[1].codec != nullptr;
// The worker publishes only outside seek execution; retain pending for later queued seeks. // Commands remain pending while executing; publication acknowledges only completed work.
state.seek_pending = std::any_of(commands.begin(), commands.end(), [this](const Command &command) { state.seek_pending = std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
return command.action == Action::Seek && command.epoch == activeEpoch; return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
}); });
state.video_aspect_ratio = 0; state.video_aspect_ratio = 0;
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) { if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
@@ -199,6 +220,8 @@ struct Player {
output.reset(); output.reset();
video.clear(); video.clear();
videoBytes = 0; videoBytes = 0;
presented = {};
skipPresentedTimestamp = AV_NOPTS_VALUE;
swr_free(&resampler); swr_free(&resampler);
av_channel_layout_uninit(&inputLayout); av_channel_layout_uninit(&inputLayout);
for (auto &track : tracks) { for (auto &track : tracks) {
@@ -253,6 +276,8 @@ struct Player {
if (!track.codec) continue; if (!track.codec) continue;
avcodec_flush_buffers(track.codec); avcodec_flush_buffers(track.codec);
track.drainSent = track.drained = false; track.drainSent = track.drained = false;
track.countedTimestamp = AV_NOPTS_VALUE;
track.timestampOccurrence = 0;
const AVStream *stream = format->streams[track.index]; const AVStream *stream = format->streams[track.index];
track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target : track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target :
std::max(target, stream->start_time * av_q2d(stream->time_base) - origin); std::max(target, stream->start_time * av_q2d(stream->time_base) - origin);
@@ -265,6 +290,8 @@ struct Player {
av_frame_unref(decoded); av_frame_unref(decoded);
video.clear(); video.clear();
videoBytes = 0; videoBytes = 0;
presented = {};
skipPresentedTimestamp = AV_NOPTS_VALUE;
pcmFrames = pcmOffset = 0; pcmFrames = pcmOffset = 0;
packetPending = demuxEnded = resamplerDrained = false; packetPending = demuxEnded = resamplerDrained = false;
receiving = -1; receiving = -1;
@@ -394,6 +421,108 @@ struct Player {
if (resume) start(); if (resume) start();
} }
void seekVideo(int64_t timestamp, double target) {
output.pause();
check(av_seek_frame(format, tracks[1].index, timestamp, AVSEEK_FLAG_BACKWARD),
"Seeking video keyframe");
resetTimeline(target);
status = PLAYER_PAUSED;
}
void presentVideo(VideoFrame frame) {
output.present(frame.frame.get());
presented = std::move(frame);
previewShown = true;
}
VideoFrame nextSteppingFrame() {
while (video.empty() && !tracks[1].drained) {
if (interrupted()) throw Cancelled{};
if (!decodeOne(true)) {
std::unique_lock<std::mutex> lock(mutex);
wake.wait_for(lock, std::chrono::milliseconds(5), [this] { return interrupted(); });
}
}
if (video.empty()) return {};
VideoFrame frame = std::move(video.front());
video.pop_front();
videoBytes -= frame.bytes;
return frame;
}
void step(int direction) {
if (!format || !tracks[1].codec) return;
pause();
status = PLAYER_PAUSED;
// A seek followed immediately by a queued step must first establish its preview.
while (!presented.frame) {
auto frame = nextSteppingFrame();
if (!frame.frame) return;
if (frame.pts + sampleTime * 0.5 < seekTarget) continue;
presentVideo(std::move(frame));
}
VideoFrame selected = std::move(presented);
const int64_t reference = presentationTimestamp(selected.frame.get());
const uint64_t referenceOccurrence = selected.timestampOccurrence;
if (reference == AV_NOPTS_VALUE)
throw std::runtime_error("Frame stepping requires video presentation timestamps");
const AVStream *stream = format->streams[tracks[1].index];
const long double ticksPerSecond = 1.0L / av_q2d(stream->time_base);
const long double originTimestamp = std::clamp(std::floor(origin * ticksPerSecond),
static_cast<long double>(std::numeric_limits<int64_t>::min() + 1),
static_cast<long double>(std::numeric_limits<int64_t>::max()));
const int64_t beginning = std::min(reference, std::max(static_cast<int64_t>(originTimestamp),
stream->start_time == AV_NOPTS_VALUE ? static_cast<int64_t>(originTimestamp) : stream->start_time));
int64_t target = reference > beginning ? reference - 1 : reference;
long double backoff = std::max(1.0L, ticksPerSecond);
for (;;) {
// FFmpeg's decoder emits presentation order, including delayed B-frames at drain.
// ponytail: a one-GOP/unindexed file can require a prefix scan; retained frames stay bounded.
seekVideo(target, 0);
VideoFrame previous{};
bool sawReference = false;
for (;;) {
auto frame = nextSteppingFrame();
if (!frame.frame) break;
const int64_t timestamp = presentationTimestamp(frame.frame.get());
const bool atReference = timestamp == reference &&
frame.timestampOccurrence == referenceOccurrence;
const bool beforeReference = timestamp < reference ||
(timestamp == reference && frame.timestampOccurrence < referenceOccurrence);
if (atReference) sawReference = true;
if (direction > 0) {
if (!beforeReference && !atReference) {
if (sawReference) selected = std::move(frame);
break;
}
} else {
if (!beforeReference) break;
if (timestamp >= beginning) previous = std::move(frame);
}
}
if ((direction > 0 && sawReference) || previous.frame || target == beginning) {
if (previous.frame) selected = std::move(previous);
break;
}
// Some demuxers land later than requested. Expand the keyframe search, not an FPS guess.
target = static_cast<int64_t>(std::max(static_cast<long double>(beginning),
static_cast<long double>(reference) - backoff));
backoff *= 2;
}
const int64_t selectedTimestamp = presentationTimestamp(selected.frame.get());
const uint64_t selectedOccurrence = selected.timestampOccurrence;
const double selectedPts = selected.pts;
// Reuse the verified preroll anchor: timestamp seeks can land after a B-frame's PTS.
// Audio is rebuilt and trimmed to selectedPts, while video skips the displayed frame.
seekVideo(target, selectedPts);
if (interrupted()) throw Cancelled{};
videoEnd = selected.end;
presentVideo(std::move(selected));
skipPresentedTimestamp = selectedTimestamp;
skipPresentedOccurrence = selectedOccurrence;
position = selectedPts;
}
void execute(const Command &command) { void execute(const Command &command) {
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return; if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
activeEpoch = command.epoch; activeEpoch = command.epoch;
@@ -402,14 +531,14 @@ struct Player {
case Action::Play: start(); break; case Action::Play: start(); break;
case Action::Pause: pause(); break; case Action::Pause: pause(); break;
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break; case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
case Action::Step: step(command.direction); break;
case Action::Close: release(); break; case Action::Close: release(); break;
} }
publish(); publish();
} }
double framePts(Track &track, const AVFrame *frame) { double framePts(Track &track, const AVFrame *frame) {
const int64_t timestamp = frame->best_effort_timestamp != AV_NOPTS_VALUE ? const int64_t timestamp = presentationTimestamp(frame);
frame->best_effort_timestamp : frame->pts;
if (timestamp == AV_NOPTS_VALUE) return track.nextPts; if (timestamp == AV_NOPTS_VALUE) return track.nextPts;
return timestamp * av_q2d(format->streams[track.index]->time_base) - origin; return timestamp * av_q2d(format->streams[track.index]->time_base) - origin;
} }
@@ -522,17 +651,34 @@ struct Player {
(!video.empty() && videoBytes > videoByteLimit - video.back().bytes); (!video.empty() && videoBytes > videoByteLimit - video.back().bytes);
} }
void queueVideo() { void queueVideo(bool stepping = false) {
auto &track = tracks[1]; auto &track = tracks[1];
const auto transfer = decoded->color_trc == AVCOL_TRC_UNSPECIFIED ? const auto transfer = decoded->color_trc == AVCOL_TRC_UNSPECIFIED ?
track.codec->color_trc : decoded->color_trc; track.codec->color_trc : decoded->color_trc;
if (transfer == AVCOL_TRC_SMPTE2084 || transfer == AVCOL_TRC_ARIB_STD_B67) if (transfer == AVCOL_TRC_SMPTE2084 || transfer == AVCOL_TRC_ARIB_STD_B67)
throw std::runtime_error("HDR PQ/HLG video is unsupported: the native view currently supports SDR only"); throw std::runtime_error("HDR PQ/HLG video is unsupported: the native view currently supports SDR only");
const int64_t timestamp = presentationTimestamp(decoded);
if (stepping && timestamp == AV_NOPTS_VALUE)
throw std::runtime_error("Frame stepping requires video presentation timestamps");
const double pts = framePts(track, decoded); const double pts = framePts(track, decoded);
const double frameDuration = decoded->duration > 0 ? const double frameDuration = decoded->duration > 0 ?
decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime; decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime;
track.nextPts = pts + frameDuration; track.nextPts = pts + frameDuration;
if (pts + sampleTime * 0.5 < seekTarget || (decoded->flags & AV_FRAME_FLAG_DISCARD)) return; if (decoded->flags & AV_FRAME_FLAG_DISCARD) return;
if (timestamp != track.countedTimestamp) {
track.countedTimestamp = timestamp;
track.timestampOccurrence = 0;
} else {
++track.timestampOccurrence;
}
if (!stepping && pts + sampleTime * 0.5 < seekTarget) return;
if (!stepping && skipPresentedTimestamp != AV_NOPTS_VALUE) {
if (timestamp != AV_NOPTS_VALUE &&
(timestamp < skipPresentedTimestamp ||
(timestamp == skipPresentedTimestamp && track.timestampOccurrence <= skipPresentedOccurrence)))
return;
skipPresentedTimestamp = AV_NOPTS_VALUE;
}
const int size = av_image_get_buffer_size(static_cast<AVPixelFormat>(decoded->format), const int size = av_image_get_buffer_size(static_cast<AVPixelFormat>(decoded->format),
decoded->width, decoded->height, 1); decoded->width, decoded->height, 1);
check(size, "Sizing decoded video"); check(size, "Sizing decoded video");
@@ -548,16 +694,20 @@ struct Player {
} }
Frame frame(av_frame_clone(decoded)); Frame frame(av_frame_clone(decoded));
if (!frame) throw std::bad_alloc(); if (!frame) throw std::bad_alloc();
VideoFrame item{std::move(frame), pts, bytes}; VideoFrame item{std::move(frame), pts, pts + frameDuration, bytes, track.timestampOccurrence};
auto insertion = std::upper_bound(video.begin(), video.end(), pts, auto insertion = std::upper_bound(video.begin(), video.end(), pts,
[](double value, const VideoFrame &entry) { return value < entry.pts; }); [](double value, const VideoFrame &entry) { return value < entry.pts; });
video.insert(insertion, std::move(item)); video.insert(insertion, std::move(item));
videoBytes += bytes; videoBytes += bytes;
} }
bool decodeOne() { bool decodeOne(bool stepping = false) {
if (pcmOffset < pcmFrames) return false; if (!stepping && pcmOffset < pcmFrames) return false;
if (receiving >= 0) { if (receiving >= 0) {
if (stepping && receiving == 0) {
receiving = -1;
return true;
}
auto &track = tracks[receiving]; auto &track = tracks[receiving];
if (receiving == 1 && videoFull() && if (receiving == 1 && videoFull() &&
(!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING)) (!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING))
@@ -573,11 +723,16 @@ struct Player {
return true; return true;
} }
check(result, "Decoding media"); check(result, "Decoding media");
if (receiving == 0) convertAudio(); else queueVideo(); if (receiving == 0) convertAudio(); else queueVideo(stepping);
av_frame_unref(decoded); av_frame_unref(decoded);
return true; return true;
} }
if (packetPending) { if (packetPending) {
if (stepping && packet->stream_index == tracks[0].index) {
av_packet_unref(packet);
packetPending = false;
return true;
}
const int slot = packet->stream_index == tracks[0].index ? 0 : 1; const int slot = packet->stream_index == tracks[0].index ? 0 : 1;
const int result = avcodec_send_packet(tracks[slot].codec, packet); const int result = avcodec_send_packet(tracks[slot].codec, packet);
if (result == AVERROR(EAGAIN)) { if (result == AVERROR(EAGAIN)) {
@@ -593,7 +748,7 @@ struct Player {
if (demuxEnded) { if (demuxEnded) {
for (int slot = 0; slot < 2; ++slot) { for (int slot = 0; slot < 2; ++slot) {
auto &track = tracks[slot]; auto &track = tracks[slot];
if (!track.codec || track.drainSent) continue; if (!track.codec || track.drainSent || (stepping && slot == 0)) continue;
const int result = avcodec_send_packet(track.codec, nullptr); const int result = avcodec_send_packet(track.codec, nullptr);
if (result == AVERROR(EAGAIN)) { if (result == AVERROR(EAGAIN)) {
receiving = slot; receiving = slot;
@@ -604,7 +759,7 @@ struct Player {
receiving = slot; receiving = slot;
return true; return true;
} }
if ((!tracks[0].codec || tracks[0].drained) && !resamplerDrained) { if (!stepping && (!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
drainResampler(); drainResampler();
return true; return true;
} }
@@ -617,7 +772,8 @@ struct Player {
} }
if (result == AVERROR(EAGAIN)) return false; if (result == AVERROR(EAGAIN)) return false;
check(result, "Reading media packet"); check(result, "Reading media packet");
if (packet->stream_index != tracks[0].index && packet->stream_index != tracks[1].index) { if ((packet->stream_index != tracks[0].index && packet->stream_index != tracks[1].index) ||
(stepping && packet->stream_index == tracks[0].index)) {
av_packet_unref(packet); av_packet_unref(packet);
} else { } else {
packetPending = true; packetPending = true;
@@ -626,16 +782,13 @@ struct Player {
} }
void renderVideo(double now) { void renderVideo(double now) {
Frame latest; VideoFrame latest{};
while (!video.empty() && video.front().pts <= now + 0.002) { while (!video.empty() && video.front().pts <= now + 0.002) {
videoBytes -= video.front().bytes; videoBytes -= video.front().bytes;
latest = std::move(video.front().frame); latest = std::move(video.front());
video.pop_front(); video.pop_front();
} }
if (latest) { if (latest.frame) presentVideo(std::move(latest));
output.present(latest.get());
previewShown = true;
}
} }
bool tick() { bool tick() {
@@ -643,10 +796,10 @@ struct Player {
bool progressed = pumpAudio(); bool progressed = pumpAudio();
if (status == PLAYER_PAUSED) { if (status == PLAYER_PAUSED) {
if (!previewShown && !video.empty()) { if (!previewShown && !video.empty()) {
output.present(video.front().frame.get()); auto frame = std::move(video.front());
videoBytes -= video.front().bytes; videoBytes -= frame.bytes;
video.pop_front(); video.pop_front();
previewShown = true; presentVideo(std::move(frame));
progressed = true; progressed = true;
} }
const bool needPreview = tracks[1].codec && !previewShown; const bool needPreview = tracks[1].codec && !previewShown;
@@ -756,6 +909,10 @@ bool player_seek(Player *player, double seconds) {
if (!std::isfinite(seconds)) return false; if (!std::isfinite(seconds)) return false;
return submit(player, [=] { return player->enqueue({Action::Seek, {}, seconds}); }); return submit(player, [=] { return player->enqueue({Action::Seek, {}, seconds}); });
} }
bool player_step_frame(Player *player, int direction) {
if (direction != -1 && direction != 1) return false;
return submit(player, [=] { return player->enqueue({Action::Step, {}, 0, direction}); });
}
bool player_close(Player *player) { bool player_close(Player *player) {
return submit(player, [=] { return player->enqueue({Action::Close, {}}); }); return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
} }
+4 -1
View File
@@ -17,7 +17,7 @@ typedef struct PlayerState {
double duration; double duration;
bool has_audio; bool has_audio;
bool has_video; bool has_video;
// True from accepted seek until all queued seeks on this timeline finish. // True until all accepted seeks/frame steps on this timeline finish.
bool seek_pending; bool seek_pending;
// Display width / height, including sample aspect ratio; zero without video. // Display width / height, including sample aspect ratio; zero without video.
double video_aspect_ratio; double video_aspect_ratio;
@@ -32,6 +32,9 @@ bool player_open(Player *player, const char *path);
bool player_play(Player *player); bool player_play(Player *player);
bool player_pause(Player *player); bool player_pause(Player *player);
bool player_seek(Player *player, double seconds); bool player_seek(Player *player, double seconds);
// Direction must be -1 (previous) or +1 (next). Requires ready video media.
// Pauses playback and selects the adjacent decoded presentation frame.
bool player_step_frame(Player *player, int direction);
bool player_close(Player *player); bool player_close(Player *player);
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes. // Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
// Rejects null handles and non-finite/out-of-range values without changing gain. // Rejects null handles and non-finite/out-of-range values without changing gain.