diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index 1142d80..9d7e21d 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -467,7 +467,7 @@ func main() { displayedVideoStride uint32 = placeholderStride fps float64 - lastIndex uint64 + dropTracker videoDropTracker dropped uint64 droppedTotal uint64 frameCount uint64 @@ -530,6 +530,8 @@ func main() { resized = false } var shownIndex uint64 + var shownGeneration uint64 + var shownSource playback.FeedConfig hasFrame := false frameCtx, frameCancel := context.WithTimeout(ctx, 100*time.Millisecond) @@ -555,6 +557,8 @@ func main() { } shownIndex = pendingFrame.Frame.Index + shownGeneration = pendingFrame.Generation + shownSource = pendingFrame.Source displayedVideoWidth = pendingFrame.Frame.Width displayedVideoHeight = pendingFrame.Frame.Height displayedVideoStride = pendingFrame.Frame.Stride @@ -565,15 +569,14 @@ func main() { panic(frameErr) } + snapshot, hasSnapshot := player.Controller.Snapshot() + // stats if hasFrame { - if lastIndex != 0 && shownIndex > lastIndex { - if g := shownIndex - lastIndex - 1; g > 0 { - dropped += g - droppedTotal += g - } + if gap := dropTracker.Observe(shownGeneration, shownSource, shownIndex); gap > 0 { + dropped += gap + droppedTotal += gap } - lastIndex = shownIndex frameCount++ if now := time.Now(); now.Sub(lastReport) >= time.Second { dt := now.Sub(lastReport).Seconds() @@ -588,7 +591,6 @@ func main() { // end of stats if r != nil { gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height)) - snapshot, hasSnapshot := player.Controller.Snapshot() if showStats { cimgui.SetNextWindowPos(cimgui.Vec2{X: 0, Y: 0}) cimgui.SetNextWindowSize(cimgui.Vec2{X: 460, Y: 510}) diff --git a/cmd/mxl-player/video_drop_tracker.go b/cmd/mxl-player/video_drop_tracker.go new file mode 100644 index 0000000..ca5c44e --- /dev/null +++ b/cmd/mxl-player/video_drop_tracker.go @@ -0,0 +1,37 @@ +package main + +import "mxl-player/internal/playback" + +// videoDropTracker counts gaps only within one playback generation. Frame +// indices belong to their source and cannot be compared across feed changes. +type videoDropTracker struct { + generation uint64 + source playback.FeedConfig + lastIndex uint64 + hasIndex bool +} + +func (t *videoDropTracker) Observe( + generation uint64, + source playback.FeedConfig, + index uint64, +) uint64 { + if !t.hasIndex || + generation != t.generation || + !sameVideoSource(source, t.source) || + index <= t.lastIndex { + t.generation = generation + t.source = source + t.lastIndex = index + t.hasIndex = true + return 0 + } + + dropped := index - t.lastIndex - 1 + t.lastIndex = index + return dropped +} + +func sameVideoSource(a, b playback.FeedConfig) bool { + return a.Domain == b.Domain && a.UUID == b.UUID +} diff --git a/cmd/mxl-player/video_drop_tracker_test.go b/cmd/mxl-player/video_drop_tracker_test.go new file mode 100644 index 0000000..5239f53 --- /dev/null +++ b/cmd/mxl-player/video_drop_tracker_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "testing" + + "mxl-player/internal/playback" +) + +func TestVideoDropTracker(t *testing.T) { + tests := []struct { + name string + observations [][2]uint64 + want []uint64 + }{ + { + name: "counts gaps within generation", + observations: [][2]uint64{{1, 10}, {1, 11}, {1, 15}}, + want: []uint64{0, 0, 3}, + }, + { + name: "higher index from new generation resets baseline", + observations: [][2]uint64{{1, 10}, {2, 1000000}, {2, 1000001}}, + want: []uint64{0, 0, 0}, + }, + { + name: "lower index from new generation resets baseline", + observations: [][2]uint64{{1, 100}, {2, 5}, {2, 7}}, + want: []uint64{0, 0, 1}, + }, + { + name: "index restart within generation resets baseline", + observations: [][2]uint64{{1, 100}, {1, 0}, {1, 1}}, + want: []uint64{0, 0, 0}, + }, + { + name: "zero is a valid first index", + observations: [][2]uint64{{1, 0}, {1, 2}}, + want: []uint64{0, 1}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var tracker videoDropTracker + source := playback.FeedConfig{Domain: "/mxl", UUID: "video"} + for index, observation := range test.observations { + got := tracker.Observe(observation[0], source, observation[1]) + if got != test.want[index] { + t.Fatalf("Observe(%d, %d) = %d, want %d", + observation[0], observation[1], got, test.want[index]) + } + } + }) + } +} + +func TestVideoDropTrackerResetsWhenSourceChanges(t *testing.T) { + var tracker videoDropTracker + first := playback.FeedConfig{Domain: "/mxl", UUID: "first"} + second := playback.FeedConfig{Domain: "/mxl", UUID: "second"} + + if got := tracker.Observe(1, first, 10); got != 0 { + t.Fatalf("first Observe() = %d, want 0", got) + } + if got := tracker.Observe(1, second, 1000000); got != 0 { + t.Fatalf("source-changing Observe() = %d, want 0", got) + } + if got := tracker.Observe(1, second, 1000002); got != 1 { + t.Fatalf("same-source Observe() = %d, want 1", got) + } +} diff --git a/flow-def/video-4k.json b/flow-def/video-4k.json new file mode 100644 index 0000000..e850f18 --- /dev/null +++ b/flow-def/video-4k.json @@ -0,0 +1,41 @@ +{ + "description": "sample for mxl reader go player", + "id": "5fbec3b1-1b0f-417d-9059-8b94a47197ed", + "tags": { + "urn:x-nmos:tag:grouphint/v1.0": [ + "mxl-gst-testsrc pattern" + ] + }, + "format": "urn:x-nmos:format:video", + "label": "SMPTE bars test video", + "parents": [], + "media_type": "video/v210", + "grain_rate": { + "numerator": 25, + "denominator": 1 + }, + "frame_width": 1920, + "frame_height": 1080, + "interlace_mode": "progressive", + "colorspace": "BT709", + "components": [ + { + "name": "Y", + "width": 1920, + "height": 1080, + "bit_depth": 10 + }, + { + "name": "Cb", + "width": 960, + "height": 1080, + "bit_depth": 10 + }, + { + "name": "Cr", + "width": 960, + "height": 1080, + "bit_depth": 10 + } + ] +} diff --git a/imgui.ini b/imgui.ini index bc38a87..d88865f 100644 --- a/imgui.ini +++ b/imgui.ini @@ -4,12 +4,11 @@ Size=400,400 Collapsed=0 [Window][Settings & Info] -Pos=1220,0 -Size=700,1080 +Pos=580,0 +Size=700,720 Collapsed=0 [Window][Stats] -Pos=0,0 Size=460,510 Collapsed=0 diff --git a/internal/playback/sync_attempt.go b/internal/playback/sync_attempt.go index 995fc67..e36b8b5 100644 --- a/internal/playback/sync_attempt.go +++ b/internal/playback/sync_attempt.go @@ -14,6 +14,7 @@ func runSyncAttempt( videoConfig FeedConfig, audioConfig FeedConfig, ) (resultErr error) { + videoCtx := withVideoSource(ctx, videoConfig) reader, err := factory.OpenSync( ctx, videoConfig, @@ -39,7 +40,7 @@ func runSyncAttempt( return fmt.Errorf("read sync group: %w", err) } - if err := videoSink.ConsumeVideo(ctx, frame.Video); err != nil { + if err := videoSink.ConsumeVideo(videoCtx, frame.Video); err != nil { if ctx.Err() != nil { return ctx.Err() } diff --git a/internal/playback/video.go b/internal/playback/video.go index 98271ee..382be1d 100644 --- a/internal/playback/video.go +++ b/internal/playback/video.go @@ -2,6 +2,17 @@ package playback import "context" +type videoSourceContextKey struct{} + +func withVideoSource(ctx context.Context, source FeedConfig) context.Context { + return context.WithValue(ctx, videoSourceContextKey{}, source) +} + +func videoSourceFromContext(ctx context.Context) FeedConfig { + source, _ := ctx.Value(videoSourceContextKey{}).(FeedConfig) + return source +} + // VideoFrame contains metadata and borrowed source payload. // // Payload is valid only until the next VideoReader.ReadVideo call or until the diff --git a/internal/playback/video_attempt.go b/internal/playback/video_attempt.go index 798f3c3..bdd4ee2 100644 --- a/internal/playback/video_attempt.go +++ b/internal/playback/video_attempt.go @@ -24,6 +24,7 @@ func runVideoAttempt( sink VideoSink, config FeedConfig, ) (resultErr error) { + ctx = withVideoSource(ctx, config) reader, err := factory.OpenVideo(ctx, config) if err != nil { return fmt.Errorf("open video: %w", err) diff --git a/internal/playback/video_bridge.go b/internal/playback/video_bridge.go index fb394d5..aa971e6 100644 --- a/internal/playback/video_bridge.go +++ b/internal/playback/video_bridge.go @@ -6,7 +6,9 @@ import ( ) type PendingVideoFrame struct { - Frame VideoFrame + Frame VideoFrame + Generation uint64 + Source FeedConfig completeOnce sync.Once result chan error @@ -27,8 +29,10 @@ func (b *VideoBridge) ConsumeVideo( frame VideoFrame, ) error { pending := &PendingVideoFrame{ - Frame: frame, - result: make(chan error, 1), + Frame: frame, + Generation: generationFromContext(ctx), + Source: videoSourceFromContext(ctx), + result: make(chan error, 1), } select { diff --git a/internal/playback/video_bridge_test.go b/internal/playback/video_bridge_test.go index 8205b9e..d66a424 100644 --- a/internal/playback/video_bridge_test.go +++ b/internal/playback/video_bridge_test.go @@ -21,8 +21,10 @@ func TestVideoBridgeDeliversFrameAndCompletionResult(t *testing.T) { } consumeResult := make(chan error, 1) + wantSource := FeedConfig{Domain: "/video", UUID: "video", Active: true} go func() { - consumeResult <- bridge.ConsumeVideo(context.Background(), wantFrame) + ctx := withGeneration(context.Background(), 17) + consumeResult <- bridge.ConsumeVideo(withVideoSource(ctx, wantSource), wantFrame) }() ctx, cancel := context.WithTimeout(context.Background(), videoBridgeTestTimeout) @@ -34,6 +36,12 @@ func TestVideoBridgeDeliversFrameAndCompletionResult(t *testing.T) { if pending.Frame.Index != wantFrame.Index { t.Fatalf("Next() frame index = %d, want %d", pending.Frame.Index, wantFrame.Index) } + if pending.Generation != 17 { + t.Fatalf("Next() generation = %d, want 17", pending.Generation) + } + if pending.Source != wantSource { + t.Fatalf("Next() source = %#v, want %#v", pending.Source, wantSource) + } if &pending.Frame.Payload[0] != &wantFrame.Payload[0] { t.Fatal("Next() copied the borrowed payload") } diff --git a/playlists/sample-list.json b/playlists/sample-list.json index 51c60e5..ffce9b2 100644 --- a/playlists/sample-list.json +++ b/playlists/sample-list.json @@ -15,6 +15,19 @@ "sync": true, "duration": "10s" }, + { + "name": "fail", + "video": { + "domain": "/dev/shm/mxl", + "uuid": "6fbec3b1-1b0f-417d-9059-8b94a47197ed" + }, + "audio": { + "domain": "/dev/shm/mxl", + "uuid": "6fbec3b1-1b0f-417d-9059-8b94a47197ec" + }, + "sync": true, + "duration": "10s" + }, { "name": "F1 Highlights", "video": {