feat: step to adjacent decoded video frames
This commit is contained in:
@@ -52,6 +52,7 @@ final class PlayerModel: ObservableObject {
|
||||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||||
}
|
||||
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 canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
@@ -155,6 +156,14 @@ final class PlayerModel: ObservableObject {
|
||||
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) {
|
||||
guard let handle else { return }
|
||||
if player_set_volume(handle, Float(value)) {
|
||||
@@ -697,6 +706,8 @@ private struct WindowChrome: NSViewRepresentable {
|
||||
switch key {
|
||||
case "j": model.seekRelative(-10)
|
||||
case "l": model.seekRelative(10)
|
||||
case ",": model.stepFrame(-1)
|
||||
case ".": model.stepFrame(1)
|
||||
case "k", " ":
|
||||
if !event.isARepeat { model.togglePlayback() }
|
||||
case "m":
|
||||
@@ -825,6 +836,9 @@ struct PlayerApp: App {
|
||||
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
|
||||
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
|
||||
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)) }
|
||||
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
|
||||
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 {
|
||||
Frame frame;
|
||||
double pts;
|
||||
double end;
|
||||
std::size_t bytes;
|
||||
uint64_t timestampOccurrence = 0;
|
||||
};
|
||||
struct Track {
|
||||
AVCodecContext *codec = nullptr;
|
||||
@@ -54,18 +56,26 @@ struct Track {
|
||||
bool drainSent = false;
|
||||
bool drained = false;
|
||||
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 {
|
||||
Action action;
|
||||
std::string path;
|
||||
double seconds = 0;
|
||||
int direction = 0;
|
||||
uint64_t epoch = 0;
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
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 {
|
||||
@@ -74,6 +84,7 @@ struct Player {
|
||||
std::condition_variable wake;
|
||||
std::deque<Command> commands;
|
||||
PlayerState state{};
|
||||
uint64_t publishedEpoch = 0;
|
||||
std::array<char, 512> requestedError{};
|
||||
bool errorRequested = false;
|
||||
uint64_t errorEpoch = 0;
|
||||
@@ -96,6 +107,9 @@ struct Player {
|
||||
int receiving = -1;
|
||||
std::deque<VideoFrame> video;
|
||||
std::size_t videoBytes = 0;
|
||||
VideoFrame presented{};
|
||||
int64_t skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
uint64_t skipPresentedOccurrence = 0;
|
||||
std::vector<float> pcm;
|
||||
std::size_t pcmFrames = 0;
|
||||
std::size_t pcmOffset = 0;
|
||||
@@ -127,6 +141,10 @@ struct Player {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (stopping.load(std::memory_order_relaxed) || commands.size() >= commandLimit)
|
||||
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;
|
||||
command.epoch = requestedEpoch.load(std::memory_order_relaxed) + (newTimeline ? 1 : 0);
|
||||
commands.push_back(std::move(command));
|
||||
@@ -137,7 +155,8 @@ struct Player {
|
||||
state.status = PLAYER_OPENING;
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
@@ -175,14 +194,16 @@ struct Player {
|
||||
void publish() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (activeEpoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
publishedEpoch = activeEpoch;
|
||||
state.status = status;
|
||||
state.position = position;
|
||||
state.duration = duration;
|
||||
state.has_audio = tracks[0].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) {
|
||||
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;
|
||||
if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) {
|
||||
@@ -199,6 +220,8 @@ struct Player {
|
||||
output.reset();
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
presented = {};
|
||||
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
swr_free(&resampler);
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
for (auto &track : tracks) {
|
||||
@@ -253,6 +276,8 @@ struct Player {
|
||||
if (!track.codec) continue;
|
||||
avcodec_flush_buffers(track.codec);
|
||||
track.drainSent = track.drained = false;
|
||||
track.countedTimestamp = AV_NOPTS_VALUE;
|
||||
track.timestampOccurrence = 0;
|
||||
const AVStream *stream = format->streams[track.index];
|
||||
track.nextPts = stream->start_time == AV_NOPTS_VALUE ? target :
|
||||
std::max(target, stream->start_time * av_q2d(stream->time_base) - origin);
|
||||
@@ -265,6 +290,8 @@ struct Player {
|
||||
av_frame_unref(decoded);
|
||||
video.clear();
|
||||
videoBytes = 0;
|
||||
presented = {};
|
||||
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
@@ -394,6 +421,108 @@ struct Player {
|
||||
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) {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
activeEpoch = command.epoch;
|
||||
@@ -402,14 +531,14 @@ struct Player {
|
||||
case Action::Play: start(); break;
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
case Action::Step: step(command.direction); break;
|
||||
case Action::Close: release(); break;
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
double framePts(Track &track, const AVFrame *frame) {
|
||||
const int64_t timestamp = frame->best_effort_timestamp != AV_NOPTS_VALUE ?
|
||||
frame->best_effort_timestamp : frame->pts;
|
||||
const int64_t timestamp = presentationTimestamp(frame);
|
||||
if (timestamp == AV_NOPTS_VALUE) return track.nextPts;
|
||||
return timestamp * av_q2d(format->streams[track.index]->time_base) - origin;
|
||||
}
|
||||
@@ -522,17 +651,34 @@ struct Player {
|
||||
(!video.empty() && videoBytes > videoByteLimit - video.back().bytes);
|
||||
}
|
||||
|
||||
void queueVideo() {
|
||||
void queueVideo(bool stepping = false) {
|
||||
auto &track = tracks[1];
|
||||
const auto transfer = decoded->color_trc == AVCOL_TRC_UNSPECIFIED ?
|
||||
track.codec->color_trc : decoded->color_trc;
|
||||
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");
|
||||
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 frameDuration = decoded->duration > 0 ?
|
||||
decoded->duration * av_q2d(format->streams[track.index]->time_base) : videoFrameTime;
|
||||
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),
|
||||
decoded->width, decoded->height, 1);
|
||||
check(size, "Sizing decoded video");
|
||||
@@ -548,16 +694,20 @@ struct Player {
|
||||
}
|
||||
Frame frame(av_frame_clone(decoded));
|
||||
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,
|
||||
[](double value, const VideoFrame &entry) { return value < entry.pts; });
|
||||
video.insert(insertion, std::move(item));
|
||||
videoBytes += bytes;
|
||||
}
|
||||
|
||||
bool decodeOne() {
|
||||
if (pcmOffset < pcmFrames) return false;
|
||||
bool decodeOne(bool stepping = false) {
|
||||
if (!stepping && pcmOffset < pcmFrames) return false;
|
||||
if (receiving >= 0) {
|
||||
if (stepping && receiving == 0) {
|
||||
receiving = -1;
|
||||
return true;
|
||||
}
|
||||
auto &track = tracks[receiving];
|
||||
if (receiving == 1 && videoFull() &&
|
||||
(!tracks[0].codec || output.queued() >= 1024 || status != PLAYER_PLAYING))
|
||||
@@ -573,11 +723,16 @@ struct Player {
|
||||
return true;
|
||||
}
|
||||
check(result, "Decoding media");
|
||||
if (receiving == 0) convertAudio(); else queueVideo();
|
||||
if (receiving == 0) convertAudio(); else queueVideo(stepping);
|
||||
av_frame_unref(decoded);
|
||||
return true;
|
||||
}
|
||||
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 result = avcodec_send_packet(tracks[slot].codec, packet);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
@@ -593,7 +748,7 @@ struct Player {
|
||||
if (demuxEnded) {
|
||||
for (int slot = 0; slot < 2; ++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);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
receiving = slot;
|
||||
@@ -604,7 +759,7 @@ struct Player {
|
||||
receiving = slot;
|
||||
return true;
|
||||
}
|
||||
if ((!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
if (!stepping && (!tracks[0].codec || tracks[0].drained) && !resamplerDrained) {
|
||||
drainResampler();
|
||||
return true;
|
||||
}
|
||||
@@ -617,7 +772,8 @@ struct Player {
|
||||
}
|
||||
if (result == AVERROR(EAGAIN)) return false;
|
||||
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);
|
||||
} else {
|
||||
packetPending = true;
|
||||
@@ -626,16 +782,13 @@ struct Player {
|
||||
}
|
||||
|
||||
void renderVideo(double now) {
|
||||
Frame latest;
|
||||
VideoFrame latest{};
|
||||
while (!video.empty() && video.front().pts <= now + 0.002) {
|
||||
videoBytes -= video.front().bytes;
|
||||
latest = std::move(video.front().frame);
|
||||
latest = std::move(video.front());
|
||||
video.pop_front();
|
||||
}
|
||||
if (latest) {
|
||||
output.present(latest.get());
|
||||
previewShown = true;
|
||||
}
|
||||
if (latest.frame) presentVideo(std::move(latest));
|
||||
}
|
||||
|
||||
bool tick() {
|
||||
@@ -643,10 +796,10 @@ struct Player {
|
||||
bool progressed = pumpAudio();
|
||||
if (status == PLAYER_PAUSED) {
|
||||
if (!previewShown && !video.empty()) {
|
||||
output.present(video.front().frame.get());
|
||||
videoBytes -= video.front().bytes;
|
||||
auto frame = std::move(video.front());
|
||||
videoBytes -= frame.bytes;
|
||||
video.pop_front();
|
||||
previewShown = true;
|
||||
presentVideo(std::move(frame));
|
||||
progressed = true;
|
||||
}
|
||||
const bool needPreview = tracks[1].codec && !previewShown;
|
||||
@@ -756,6 +909,10 @@ bool player_seek(Player *player, double seconds) {
|
||||
if (!std::isfinite(seconds)) return false;
|
||||
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) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ typedef struct PlayerState {
|
||||
double duration;
|
||||
bool has_audio;
|
||||
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;
|
||||
// Display width / height, including sample aspect ratio; zero without video.
|
||||
double video_aspect_ratio;
|
||||
@@ -32,6 +32,9 @@ bool player_open(Player *player, const char *path);
|
||||
bool player_play(Player *player);
|
||||
bool player_pause(Player *player);
|
||||
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);
|
||||
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
|
||||
// Rejects null handles and non-finite/out-of-range values without changing gain.
|
||||
|
||||
Reference in New Issue
Block a user