feat: step to adjacent decoded video frames
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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, {}}); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user