fix: count repeated frame steps and retain the forward decoder cursor
This commit is contained in:
@@ -80,6 +80,45 @@ size_t firstActive(const float *samples, size_t frames, float threshold = .002f)
|
||||
require(first < frames, "tone is present");
|
||||
return first;
|
||||
}
|
||||
void playStepped(const std::string &path, const std::vector<float> &reference) {
|
||||
capturedFrames = 0;
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
require(bool(player), "create stepped audio player");
|
||||
auto wait = [&](auto ready) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(12);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto state = player_get_state(player.get());
|
||||
require(state.status != PLAYER_ERROR, state.error);
|
||||
if (ready(state)) return state;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
}
|
||||
require(false, "stepped audio operation completes");
|
||||
return PlayerState{};
|
||||
};
|
||||
require(player_open(player.get(), path.c_str()), "open video with PCM audio");
|
||||
wait([](auto s) { return s.status == PLAYER_PAUSED; });
|
||||
require(player_step_frame(player.get(), -1), "establish first frame");
|
||||
wait([](auto s) { return !s.seek_pending; });
|
||||
for (int i = 0; i < 32; ++i) require(player_step_frame(player.get(), 1), "queue forward audio frame steps");
|
||||
for (int i = 0; i < 19; ++i) require(player_step_frame(player.get(), -1), "queue backward audio frame steps");
|
||||
auto selected = wait([](auto s) { return !s.seek_pending; });
|
||||
require(std::abs(selected.position - 13.0 / 24) < .0001, "resume starts at selected video frame");
|
||||
require(capturedFrames == 0, "frame stepping never starts audio output");
|
||||
require(player_play(player.get()), "resume audio after repeated steps");
|
||||
auto ended = wait([](auto s) { return s.status == PLAYER_ENDED; });
|
||||
require(std::abs(ended.position - 2) < .0001, "stepped audio reaches original media end");
|
||||
player.reset(); // Stop/join before inspecting callback-owned storage.
|
||||
constexpr size_t sourceStart = 26000; // 13 / 24 seconds at 48 kHz.
|
||||
const size_t remaining = reference.size() / 2 - sourceStart;
|
||||
const size_t offset = firstActive(capture.data(), capturedFrames);
|
||||
require(capturedFrames >= offset + remaining, "all remaining PCM reaches the callback");
|
||||
float maximumError = 0;
|
||||
for (size_t i = 0; i < remaining * 2; ++i)
|
||||
maximumError = std::max(maximumError,
|
||||
std::abs(capture[offset * 2 + i] - reference[sourceStart * 2 + i]));
|
||||
std::printf("stepped resume: max remaining PCM error %.8f\n", maximumError);
|
||||
require(maximumError < .00003f, "resume audio matches selected-frame suffix, without stale or dropped samples");
|
||||
}
|
||||
}
|
||||
|
||||
// Linked only into the check build. No allocation, locks, or file I/O in callback.
|
||||
@@ -115,6 +154,7 @@ int main(int argc, char **argv) {
|
||||
std::printf("%s: max sample error %.8f\n", name, maximumError);
|
||||
require(maximumError < .00003f, "continuous callback PCM matches reference (no inserted/dropped samples)");
|
||||
if (std::string(name) == "tone480.wav") {
|
||||
playStepped(directory + "/tone-step.mov", reference);
|
||||
play(path, .25f);
|
||||
size_t quietStart = firstActive(capture.data(), capturedFrames, .002f * .25f);
|
||||
require(quietStart >= referenceStart, "fractional-volume alignment");
|
||||
|
||||
+23
-1
@@ -121,9 +121,31 @@ static void frameSteps(Player *player, MTKView *view, const std::string &directo
|
||||
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_open(player, (directory + "/av.mp4").c_str()), "repeated stepping open");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; });
|
||||
step(player, -1, 0);
|
||||
revision = player_get_state(player).revision;
|
||||
for (int i = 0; i < 64; ++i)
|
||||
require(player_step_frame(player, 1), "held forward steps accepted without queue overflow");
|
||||
frameCompleted(player, revision, 64.0 / 24);
|
||||
revision = player_get_state(player).revision;
|
||||
for (int i = 0; i < 64; ++i)
|
||||
require(player_step_frame(player, -1), "held backward steps accepted without queue overflow");
|
||||
frameCompleted(player, revision, 0);
|
||||
revision = player_get_state(player).revision;
|
||||
for (int i = 0; i < 40; ++i) require(player_step_frame(player, 1), "queue forward step run");
|
||||
require(player_seek(player, 1.5), "seek separates repeated step runs");
|
||||
for (int i = 0; i < 32; ++i) require(player_step_frame(player, -1), "queue reverse step run");
|
||||
for (int i = 0; i < 24; ++i) require(player_step_frame(player, 1), "queue direction reversal");
|
||||
frameCompleted(player, revision, 28.0 / 24);
|
||||
// A new media timeline supersedes the entire pending repeated run.
|
||||
for (int i = 0; i < 4096; ++i) require(player_step_frame(player, 1), "queue long repeated run");
|
||||
require(player_open(player, (directory + "/audio.wav").c_str()), "open supersedes repeated steps");
|
||||
auto replacement = until(player, [](auto s) { return s.status == PLAYER_PAUSED && !s.has_video && !s.seek_pending; });
|
||||
require(replacement.position == 0 && replacement.has_audio, "stale steps do not affect replacement media");
|
||||
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");
|
||||
std::puts("PASS exact VFR/B-frame steps/repeat bursts/seek ordering/pause/resume/boundaries/timeline replacement");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
|
||||
@@ -56,6 +56,7 @@ check-audio: .build/audio-continuity-check
|
||||
mkdir -p .build/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=44100 -t 2 -c:a pcm_s16le .build/fixtures/tone441.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i sine=frequency=440:sample_rate=48000 -t 2 -c:a pcm_s16le .build/fixtures/tone480.wav
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=24:duration=2 -i .build/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy .build/fixtures/tone-step.mov
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -c:a copy .build/fixtures/tone441.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -c:a aac .build/fixtures/tone-aac.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.wav -af "asetpts='PTS+gte(T,1)*0.25/TB'" -c:a pcm_s16le .build/fixtures/tone-gap.mka
|
||||
|
||||
@@ -35,7 +35,7 @@ Opening video fits the window to its display aspect ratio, including non-square
|
||||
|
||||
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. The last presented image remains visible during decoder preroll and queued steps; opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
|
||||
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. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. 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.
|
||||
- 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.
|
||||
@@ -58,9 +58,9 @@ Only implemented actions have controls; there are no placeholder playlist, subti
|
||||
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, 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.
|
||||
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, repeated-step bursts beyond the ordinary command capacity, mixed seek/direction ordering, replacement media superseding a long pending run, 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 verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also 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.
|
||||
|
||||
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
|
||||
|
||||
@@ -72,6 +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.
|
||||
|
||||
Frame stepping re-decodes from an earlier keyframe and rebuilds audio at the selected timestamp. Intermediate decoded frames are discarded, but inter-frame dependencies still require decoding them. Every accepted repeated step counts; commands are not coalesced or skipped. Long-GOP or unindexed files can therefore step slowly even though the image no longer blanks; 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.
|
||||
Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. 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.
|
||||
|
||||
@@ -159,7 +159,7 @@ final class PlayerModel: ObservableObject {
|
||||
func stepFrame(_ direction: Int32) {
|
||||
guard canStepFrame, let handle else { return }
|
||||
let accepted = player_step_frame(handle, direction)
|
||||
if accepted { seekPending = true }
|
||||
if accepted { seekPending = false }
|
||||
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||||
revealControls()
|
||||
}
|
||||
|
||||
+60
-14
@@ -66,6 +66,7 @@ struct Command {
|
||||
double seconds = 0;
|
||||
int direction = 0;
|
||||
uint64_t epoch = 0;
|
||||
uint64_t repetitions = 1;
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -110,6 +111,9 @@ struct Player {
|
||||
VideoFrame presented{};
|
||||
int64_t skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
uint64_t skipPresentedOccurrence = 0;
|
||||
bool frameStepping = false;
|
||||
// ponytail: long forward runs can make resume preroll long; refresh verified anchors if that latency matters.
|
||||
int64_t steppingAnchor = AV_NOPTS_VALUE;
|
||||
std::vector<float> pcm;
|
||||
std::size_t pcmFrames = 0;
|
||||
std::size_t pcmOffset = 0;
|
||||
@@ -139,14 +143,25 @@ struct Player {
|
||||
|
||||
bool enqueue(Command command) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (stopping.load(std::memory_order_relaxed) || commands.size() >= commandLimit)
|
||||
return false;
|
||||
if (stopping.load(std::memory_order_relaxed)) 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);
|
||||
if (command.action == Action::Step && !commands.empty()) {
|
||||
auto &last = commands.back();
|
||||
if (last.action == Action::Step && last.direction == command.direction &&
|
||||
last.epoch == command.epoch) {
|
||||
if (last.repetitions == std::numeric_limits<uint64_t>::max()) return false;
|
||||
++last.repetitions;
|
||||
state.seek_pending = true;
|
||||
wake.notify_one();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (commands.size() >= commandLimit) return false;
|
||||
commands.push_back(std::move(command));
|
||||
if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed);
|
||||
if (commands.back().action == Action::Open) {
|
||||
@@ -222,6 +237,8 @@ struct Player {
|
||||
videoBytes = 0;
|
||||
presented = {};
|
||||
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
frameStepping = false;
|
||||
steppingAnchor = AV_NOPTS_VALUE;
|
||||
swr_free(&resampler);
|
||||
av_channel_layout_uninit(&inputLayout);
|
||||
for (auto &track : tracks) {
|
||||
@@ -292,6 +309,8 @@ struct Player {
|
||||
videoBytes = 0;
|
||||
presented = {};
|
||||
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
frameStepping = false;
|
||||
steppingAnchor = AV_NOPTS_VALUE;
|
||||
pcmFrames = pcmOffset = 0;
|
||||
packetPending = demuxEnded = resamplerDrained = false;
|
||||
receiving = -1;
|
||||
@@ -385,6 +404,18 @@ struct Player {
|
||||
if (!format || status == PLAYER_ERROR || status == PLAYER_EMPTY) return;
|
||||
if (status == PLAYER_ENDED) seek(0, false);
|
||||
if (status == PLAYER_PLAYING) return;
|
||||
if (frameStepping) {
|
||||
VideoFrame selected = std::move(presented);
|
||||
const int64_t timestamp = presentationTimestamp(selected.frame.get());
|
||||
const uint64_t occurrence = selected.timestampOccurrence;
|
||||
const double selectedPts = selected.pts;
|
||||
// Audio was skipped while stepping. Rebuild it once, only on resume.
|
||||
seekVideo(steppingAnchor, selectedPts);
|
||||
videoEnd = selected.end;
|
||||
presentVideo(std::move(selected));
|
||||
skipPresentedTimestamp = timestamp;
|
||||
skipPresentedOccurrence = occurrence;
|
||||
}
|
||||
if (tracks[0].codec) {
|
||||
std::string deviceError;
|
||||
if (!output.start(deviceError))
|
||||
@@ -462,6 +493,13 @@ struct Player {
|
||||
if (frame.pts + sampleTime * 0.5 < seekTarget) continue;
|
||||
presentVideo(std::move(frame));
|
||||
}
|
||||
if (frameStepping && direction > 0) {
|
||||
// The stepping decoder is already positioned immediately after the displayed frame.
|
||||
auto next = nextSteppingFrame();
|
||||
if (next.frame) presentVideo(std::move(next));
|
||||
position = presented.pts;
|
||||
return;
|
||||
}
|
||||
VideoFrame selected = std::move(presented);
|
||||
const int64_t reference = presentationTimestamp(selected.frame.get());
|
||||
const uint64_t referenceOccurrence = selected.timestampOccurrence;
|
||||
@@ -497,7 +535,14 @@ struct Player {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!beforeReference) break;
|
||||
if (!beforeReference) {
|
||||
if (previous.frame) {
|
||||
// Preserve the adjacent successor for a subsequent forward step.
|
||||
videoBytes += frame.bytes;
|
||||
video.push_front(std::move(frame));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (timestamp >= beginning) previous = std::move(frame);
|
||||
}
|
||||
}
|
||||
@@ -510,18 +555,11 @@ struct Player {
|
||||
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;
|
||||
position = selected.pts;
|
||||
steppingAnchor = target;
|
||||
frameStepping = true;
|
||||
presentVideo(std::move(selected));
|
||||
skipPresentedTimestamp = selectedTimestamp;
|
||||
skipPresentedOccurrence = selectedOccurrence;
|
||||
position = selectedPts;
|
||||
}
|
||||
|
||||
void execute(const Command &command) {
|
||||
@@ -794,6 +832,7 @@ struct Player {
|
||||
|
||||
bool tick() {
|
||||
if (!format || interrupted() || status == PLAYER_ENDED || status == PLAYER_ERROR) return false;
|
||||
if (frameStepping) return false;
|
||||
bool progressed = pumpAudio();
|
||||
if (status == PLAYER_PAUSED) {
|
||||
if (!previewShown && !video.empty()) {
|
||||
@@ -845,8 +884,15 @@ struct Player {
|
||||
errorRequested = false;
|
||||
haveFailure = true;
|
||||
} else if (!commands.empty()) {
|
||||
command = std::move(commands.front());
|
||||
auto &next = commands.front();
|
||||
if (next.action == Action::Step && next.repetitions > 1 &&
|
||||
next.epoch == requestedEpoch.load(std::memory_order_relaxed)) {
|
||||
command = {Action::Step, {}, 0, next.direction, next.epoch};
|
||||
--next.repetitions;
|
||||
} else {
|
||||
command = std::move(next);
|
||||
commands.pop_front();
|
||||
}
|
||||
haveCommand = true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user