diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index ab2bd8d..73ff4eb 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "mxl-player/internal/imgui" "mxl-player/internal/renderer" "mxl-player/internal/sdl" "mxl-player/internal/source" @@ -14,8 +15,8 @@ import ( "time" "unsafe" + cimgui "github.com/AllenDang/cimgui-go/imgui" vk "github.com/christerso/vulkan-go/vk" - "github.com/qvest-digital/go-mxl/mxl" pflag "github.com/spf13/pflag" ) @@ -76,8 +77,10 @@ func checkMXLargs(args appArgs) { } func main() { - // video: 5fbec3b1-1b0f-417d-9059-8b94a47197ed - // audio: 5fbec3b1-1b0f-417d-9059-8b94a47197ec + // timelapse video: 5fbec3b1-1b0f-417d-9059-8b94a47197ed + // timelapse audio: 5fbec3b1-1b0f-417d-9059-8b94a47197ec + // f1 video: 5fbec3b1-1b0f-417d-9059-8b94a47197ef + // f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb var args appArgs flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError) flagSet.Usage = func() { printUsage(os.Stderr) } @@ -137,6 +140,13 @@ func main() { return } + // ImGui init + gui := imgui.New() + defer gui.Destroy() + sdl.StartTextInput(windowHandler) + defer sdl.StopTextInput(windowHandler) + // fin on ImGui init + if err := vk.Load(); err != nil { panic(err) } @@ -250,7 +260,6 @@ func main() { fmt.Printf("sync: video %dx%d audio %dch batch=%d\n", syncSrc.Width(), syncSrc.Height(), aChans, audioBatch) case args.VideoFlowId != "": - videoSrc, err = source.Open(args.Domain, args.VideoFlowId) if err != nil { log.Fatalf("source: %v", err) @@ -317,71 +326,110 @@ func main() { panic(err) } defer r.Destroy() + guiBackend, err := imgui.NewVulkanBackend( + vkPhysDevice, vkDevice, vkQueue, r.CmdPool(), r.RenderPass()) + if err != nil { + panic(err) + } + defer guiBackend.Destroy() + r.ImGuiDraw = func(cmd vk.CommandBuffer) { + guiBackend.RecordDraw(cmd, gui.LastDrawData()) + } defer vkDevice.WaitIdle() } + // GUI state (accessible from doReconnect + goroutine) + var ( + domainStr string = args.Domain + videoStr string = args.VideoFlowId + audioStr string = args.AudioFlowId + showStats bool = true + ) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - grant := make(chan struct{}, 1) + type reconnectParams struct { + domain string + video string + audio string + } + // One control channel: grant (empty params) or reconnect (with params). + control := make(chan reconnectParams, 1) staged := make(chan uint64) - failed := make(chan struct{}) - reopen := func() error { + reopen := func(params reconnectParams) error { + // Close current sources if syncSrc != nil { _ = syncSrc.Close() + syncSrc = nil } if videoSrc != nil { _ = videoSrc.Close() + videoSrc = nil } if audioSrc != nil { _ = audioSrc.Close() + audioSrc = nil } - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - if syncSrc != nil { - s, e := source.OpenSync(args.Domain, args.VideoFlowId, args.AudioFlowId) - if e == nil { - if r != nil { - newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) - if newSize != r.FrameSize() { - if e = r.RecreateBuffers(newSize); e != nil { - return e - } + // Try once. Return error if fails — caller loops back to select + // and can pick up new reconnect params or a new grant. + if params.video != "" && params.audio != "" { + s, e := source.OpenSync(params.domain, params.video, params.audio) + if e == nil { + if r != nil { + newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) + if newSize != r.FrameSize() { + if e = r.RecreateBuffers(newSize); e != nil { + return e } } - syncSrc = s - return nil } - log.Printf("source: reopen retry: %v", e) - } else if videoSrc != nil { - s, e := source.Open(args.Domain, args.VideoFlowId) - if e == nil { - if r != nil { - newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) - if newSize != r.FrameSize() { - if e = r.RecreateBuffers(newSize); e != nil { - return e - } + syncSrc = s + aChans = s.Channels() + audioBatch = uint64(s.AudioRate().Num) / uint64(s.Rate().Num) + if audioBatch == 0 { + audioBatch = 1 + } + return nil + } + return e + } else if params.video != "" { + s, e := source.Open(params.domain, params.video) + if e == nil { + if r != nil { + newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height()) + if newSize != r.FrameSize() { + if e = r.RecreateBuffers(newSize); e != nil { + return e } } - videoSrc = s - return nil } - log.Printf("source: reopen retry: %v", e) - } else if audioSrc != nil { - s, e := source.OpenAudio(args.Domain, args.AudioFlowId) - if e == nil { - audioSrc = s - return nil - } - log.Printf("source: reopen retry: %v", e) + videoSrc = s + return nil } - time.Sleep(500 * time.Millisecond) + return e + } else if params.audio != "" { + s, e := source.OpenAudio(params.domain, params.audio) + if e == nil { + audioSrc = s + aChans = s.Channels() + audioBatch = uint64(s.Rate().Num) / (100 * uint64(s.Rate().Den)) + if audioBatch == 0 { + audioBatch = 1 + } + return nil + } + return e } + return fmt.Errorf("reopen: no flow specified") + } + + doReconnect := func() { + select { + case <-control: + default: + } + control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr} } go func() { @@ -391,6 +439,25 @@ func main() { select { case <-ctx.Done(): return + case params := <-control: + if params.video != "" || params.audio != "" { + if rerr := reopen(params); rerr != nil { + if errors.Is(rerr, context.Canceled) { + return + } + log.Printf("source: reopen failed: %v, retrying", rerr) + select { + case <-time.After(500 * time.Millisecond): + case <-ctx.Done(): + return + } + select { + case control <- params: + default: + } + } + } + continue default: } queued := sdl.GetAudioStreamQueued(audioStream) @@ -408,18 +475,22 @@ func main() { if errors.Is(err, context.Canceled) { return } - if errors.Is(err, mxl.ErrFlowInvalid) { - log.Printf("source: flow invalid, reopening") - if rerr := reopen(); rerr != nil { - log.Printf("source: reopen failed: %v", rerr) - cancel() - return - } - continue - } log.Printf("source: %v", err) - cancel() - return + params := reconnectParams{domain: domainStr, video: videoStr, audio: audioStr} + select { + case <-control: + default: + } + select { + case <-time.After(500 * time.Millisecond): + case <-ctx.Done(): + return + } + select { + case control <- params: + default: + } + continue } if f.Samples != nil && audioStream != 0 { sdl.PutAudioStreamData(audioStream, interleaveAudio(f.Samples)) @@ -429,10 +500,29 @@ func main() { // Video (with or without sync) mode: grant/staged handshake. for { - select { - case <-grant: - case <-ctx.Done(): - return + params := <-control + if params.video != "" || params.audio != "" { + // Reconnect request from Connect button or auto-retry. + select { + case <-control: // drain any pending grant + default: + } + if rerr := reopen(params); rerr != nil { + if errors.Is(rerr, context.Canceled) { + return + } + log.Printf("source: reopen failed: %v, retrying", rerr) + select { + case <-time.After(500 * time.Millisecond): + case <-ctx.Done(): + return + } + select { + case control <- params: + default: + } + } + continue } var payload []byte @@ -444,23 +534,18 @@ func main() { if errors.Is(err, context.Canceled) { return } - if errors.Is(err, mxl.ErrFlowInvalid) { - log.Printf("source: flow invalid, reopening") - if rerr := reopen(); rerr != nil { - log.Printf("source: reopen failed: %v", rerr) - cancel() - return - } - select { - case failed <- struct{}{}: - case <-ctx.Done(): - return - } - continue - } log.Printf("source: %v", err) - cancel() - return + // Drain any pending grant, then send reconnect. + select { + case <-control: + default: + } + select { + case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}: + case <-ctx.Done(): + return + } + continue } payload = vFrame.Payload grainIdx = vFrame.Index @@ -474,27 +559,20 @@ func main() { if errors.Is(err, context.Canceled) { return } - if errors.Is(err, mxl.ErrFlowInvalid) { - log.Printf("source: flow invalid, reopening") - if rerr := reopen(); rerr != nil { - log.Printf("source: reopen failed: %v", rerr) - cancel() - return - } - select { - case failed <- struct{}{}: - case <-ctx.Done(): - return - } - continue - } log.Printf("source: %v", err) - cancel() - return + select { + case <-control: + default: + } + select { + case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}: + case <-ctx.Done(): + return + } + continue } payload = f.Payload grainIdx = f.Index - } if r != nil { @@ -516,11 +594,15 @@ func main() { sdl.SetWindowFullscreen(windowHandler, true) } var ( + fps float64 lastIndex uint64 dropped uint64 frameCount uint64 lastReport time.Time + lastFrame time.Time ) + lastFrame = time.Now() + for running { frameStart := time.Now() var event [128]byte @@ -532,16 +614,24 @@ func main() { case sdl.EventWindowResized, sdl.EventPixelSizeChanged: resized = true case sdl.EventKeyDown: + if gui.IO().WantCaptureKeyboard() { + break + } key := *(*int32)(unsafe.Pointer(&event[28])) switch uint32(key) { + case sdl.KeyQ: + fallthrough case sdl.KeyEscape: running = false case sdl.KeyF: fullscreen = !fullscreen sdl.SetWindowFullscreen(windowHandler, fullscreen) resized = true + case sdl.KeyF1: + showStats = !showStats } } + gui.ProcessEvent(&event) } if !running { break @@ -558,7 +648,7 @@ func main() { } if !granted { select { - case grant <- struct{}{}: + case control <- reconnectParams{}: granted = true case <-ctx.Done(): running = false @@ -566,20 +656,77 @@ func main() { } } var shownIndex uint64 + hasFrame := false select { case shownIndex = <-staged: granted = false - case <-failed: - granted = false - continue + hasFrame = true case <-ctx.Done(): running = false continue case <-time.After(100 * time.Millisecond): - continue + // No frame staged. Reset granted so we re-grant on next iteration. + granted = false } + // stats + if hasFrame { + if lastIndex != 0 && shownIndex > lastIndex { + if g := shownIndex - lastIndex - 1; g > 0 { + dropped += g + } + } + lastIndex = shownIndex + frameCount++ + if now := time.Now(); now.Sub(lastReport) >= time.Second { + dt := now.Sub(lastReport).Seconds() + fps = float64(frameCount) / dt + fmt.Printf("fps=%.1f dropped=%d idx=%d frameTime=%.2fms\n", + fps, dropped, shownIndex, float64(now.Sub(frameStart).Microseconds())/1000.0) + frameCount = 0 + dropped = 0 + lastReport = now + } + } + // end of stats if r != nil { + gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height)) + // test widget + // cimgui.Begin("Test") + if showStats { + cimgui.SetNextWindowPos(cimgui.Vec2{X: 10, Y: 10}) + cimgui.SetNextWindowSize(cimgui.Vec2{X: 200, Y: 200}) + cimgui.BeginV("Stats", &showStats, + cimgui.WindowFlagsNoTitleBar|cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar) + cimgui.Text(fmt.Sprintf("FPS: %.1f", fps)) + cimgui.Text(fmt.Sprintf("Dropped: %d", dropped)) + cimgui.Text(fmt.Sprintf("Index: %d", shownIndex)) + if videoSrc != nil { + cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", videoSrc.Width(), videoSrc.Height(), + float32(videoSrc.Rate().Num/videoSrc.Rate().Den))) + } + if syncSrc != nil { + cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", syncSrc.Width(), syncSrc.Height(), + float32(syncSrc.Rate().Num/syncSrc.Rate().Den))) + cimgui.Text(fmt.Sprintf("Audio: %dch %dkHz", syncSrc.Channels(), syncSrc.AudioRate().Num)) + } + cimgui.Text("\nPress F1 to hide stats") + cimgui.Text("Q or Esc to quit") + cimgui.Text("F for fullscreen") + cimgui.End() + } + cimgui.Begin("Connection") + cimgui.InputTextWithHint("Domain", "/dev/shm/mxl", &domainStr, 0, nil) + cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) + cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) + cimgui.Checkbox("Show stats", &showStats) + if cimgui.Button("Connect") { + doReconnect() + } + cimgui.End() + gui.EndFrame() + lastFrame = time.Now() + // end of test widget var w, h, stride uint32 if syncSrc != nil { w, h, stride = syncSrc.Width(), syncSrc.Height(), syncSrc.Stride() @@ -601,22 +748,6 @@ func main() { panic(err) } - if lastIndex != 0 && shownIndex > lastIndex { - if g := shownIndex - lastIndex - 1; g > 0 { - dropped += g - } - } - lastIndex = shownIndex - frameCount++ - if now := time.Now(); now.Sub(lastReport) >= time.Second { - dt := now.Sub(lastReport).Seconds() - fps := float64(frameCount) / dt - fmt.Printf("fps=%.1f dropped=%d idx=%d frameTime=%.2fms\n", - fps, dropped, shownIndex, float64(now.Sub(frameStart).Microseconds())/1000.0) - frameCount = 0 - dropped = 0 - lastReport = now - } } else { time.Sleep(10 * time.Millisecond) } diff --git a/fixes.md b/fixes.md index 9f53079..02d348b 100644 --- a/fixes.md +++ b/fixes.md @@ -1,14 +1,23 @@ +# Useful links +https://pthom.github.io/imgui_explorer/ + # List of bugs, that need to be fixed ## Major +- check how it looks like with more than 2 audio channels + ## Minor - hotkeys on keyDown, especially toggle keys. Way to recreate: press F and hold ## TODO: +- some sort of playlist with id's - CLI option to run fullscreen - fabrics bridge reader. Step by step. Start with local - basic UI: stats, fields for domain, flow ids, label, etc. - snapshot - waveform, vectorscope - some image, when audio only -- q for quit + +## Done +- [x] resize broken again +- [x] q for quit diff --git a/go.mod b/go.mod index a9af25a..475a75a 100644 --- a/go.mod +++ b/go.mod @@ -10,3 +10,5 @@ require ( require github.com/qvest-digital/go-mxl v1.1.0-rc.1 require github.com/spf13/pflag v1.0.10 + +require github.com/AllenDang/cimgui-go v1.5.0 diff --git a/go.sum b/go.sum index db4e0ae..525b5a2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/AllenDang/cimgui-go v1.5.0 h1:wnS4h1wWyE+3x59cHJnNSszka7pIR+pehim6ifOk9Rg= +github.com/AllenDang/cimgui-go v1.5.0/go.mod h1:w7LE33Ex/ZOHqaFBWBHOIVMY4yLSwGLpJMUpX/fb5p4= github.com/christerso/vulkan-go v0.0.0-20260618152204-bff25e5b7646 h1:xfc6rFxs+U4XktxQa7jyPfg9g0xlb5STDsQn8sURsyo= github.com/christerso/vulkan-go v0.0.0-20260618152204-bff25e5b7646/go.mod h1:DIo6W2yFav1DflNKc0W0Oc7mecbdBg2btsaHhIYmwUA= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= diff --git a/gui.md b/gui.md new file mode 100644 index 0000000..4f1e7bc --- /dev/null +++ b/gui.md @@ -0,0 +1,233 @@ +# M9 — GUI (Dear ImGui) + +## Context + +The player already works (M0–M8): SDL3 window (purego), Vulkan renderer +(`internal/renderer`, `christerso/vulkan-go/vk` wrapper), MXL source +(`internal/source`). All rendering happens inside one render pass, one +command buffer, single-flight (fence-gated). + +Dear ImGui is a header-only C++ library. The Go binding `cimgui-go` +compiles its core at build time via cgo. cimgui-go ships SDL2 + Vulkan +backends in C++, but we use SDL3 via purego, so we write custom Go +backends that bridge to our existing SDL3 and Vulkan code. ImGui's core +(no backend) has no SDL/Vulkan dependency — it just produces draw data +(vertices, indices, commands). Our backends feed it input and render its +draw data. + +Architecture: + +``` +cmd/mxl-player/main.go + | + +-- internal/imgui/ new package + | imgui.go context, NewFrame/EndFrame, draw data + | input_sdl3.go SDL3 events -> ImGui IO + | backend_vulkan.go Vulkan pipeline + font atlas + draw + | + +-- internal/renderer/ existing, unchanged + +-- internal/sdl/ existing, add a few input helpers + +-- internal/source/ existing, unchanged +``` + +The ImGui Vulkan backend renders *inside* the existing render pass, +after the video `Draw(3)`, before `EndRenderPass`. It has its own +pipeline, vertex/index buffers, font texture, and descriptor set — +all owned by `internal/imgui`, not `internal/renderer`. + +## Steps + +### M9.1 — ImGui core init + SDL3 input backend + +Goal: ImGui context exists, receives SDL3 input, produces draw data +(not yet rendered). + +1. `go get github.com/AllenDang/cimgui-go@v1.5.0` +2. Create `internal/imgui/imgui.go`: + - `type Context struct { ctx *imgui.Context; io *imgui.IO }` + - `func New() *Context` — creates context + IO, sets display size + from `sdl.GetWindowSizeInPixels`, sets delta time each frame. + - `func (c *Context) BeginFrame(dt time.Duration, winW, winH int32)` + — updates IO (display size, delta time), calls `imgui.NewFrame()`. + - `func (c *Context) EndFrame() *imgui.DrawData` — calls + `imgui.EndFrame()` / `imgui.Render()`, returns draw data for the + Vulkan backend to consume. + - `func (c *Context) Destroy()` +3. Create `internal/imgui/input_sdl3.go`: + - `func (c *Context) ProcessEvent(event *[128]byte)` — called from + the main loop for every SDL3 event, mutates `c.io`: + - `SDL_EVENT_KEY_DOWN` / `SDL_EVENT_KEY_UP` → set key in + `io.AddInputCharactersUTF8` for text, set key index. + - `SDL_EVENT_MOUSE_BUTTON_DOWN` / `UP` → set mouse button. + - `SDL_EVENT_MOUSE_MOTION` → set mouse position. + - `SDL_EVENT_MOUSE_WHEEL` → set wheel delta. + - `SDL_EVENT_WINDOW_RESIZED` → set display size. + - Key mapping: SDL3 scancode → ImGui key enum (a static lookup table + or switch). + - Mouse: ImGui wants float coordinates; SDL3 gives int32. +4. Wire into `main.go`: + - After window creation: `imguiCtx := imgui.New()` + - `defer imguiCtx.Destroy()` + - In the event poll loop: `imguiCtx.ProcessEvent(&event)` + - Before any widget code: `imguiCtx.BeginFrame(dt, winW, winH)` +5. SDL3 text input: call `SDL_StartTextInput` on window creation so + `SDL_EVENT_TEXT_INPUT` events fire (add to `internal/sdl`). + - SDL3 text input event: `SDL_EVENT_TEXT_INPUT = 0x303`, data is a + UTF-8 string at a fixed offset in the event struct. + +Verify: app runs, no crash, window shows video as before. ImGui is +initialized but invisible (no widgets yet, no render backend). + +### M9.2 — ImGui Vulkan render backend + +Goal: ImGui draw data appears on screen inside the existing render +pass. + +1. Create `internal/imgui/backend_vulkan.go`: + - `type VulkanBackend struct { ... }` + - Holds: pipeline, pipeline layout, descriptor set layout/pool/set, + font image + view + sampler, vertex buffer, index buffer (all + `vk.*` types from the wrapper). +2. `func NewVulkanBackend(pd vk.PhysicalDevice, dev vk.Device, queue + vk.Queue, cmdPool vk.CommandPool, rp vk.RenderPass, format + vk.Format) (*VulkanBackend, error)`: + - **Font atlas**: `imgui.GetIO().Fonts.Build()` then + `GetTexDataAsRGBA32(&pixels, &w, &h)` → create + `vk.CreateImage2D(w, h, Sampled|TransferDst)` → + `vk.CreateImageView` → upload pixels via staging buffer + + `CopyBufferToImage` → `vk.CreateSampler`. + - **Descriptor set**: layout with one + `DescriptorCombinedImageSampler` binding (binding 0, fragment + stage) → pool → set → + `vk.UpdateImageDescriptor(set, 0, fontView, fontSampler)`. + - **Pipeline layout**: one set layout, push constants + (ImGui's `ImDrawVert`-based push constant, 16 bytes for the + scale/translate vec2s, vertex+fragment stages). + - **Graphics pipeline**: `vk.CreateGraphicsPipeline` with + `Blend: true`, vertex shader + fragment shader (embed ImGui's + SPIR-V or compile from `imgui_impl_vulkan`'s GLSL). Vertex + attributes: position (vec2), UV (vec2), color (vec4) — matches + `ImDrawVert` layout. + - **Vertex/index buffers**: created with a max size (e.g. 1 MB + vertex, 256 KB index); recreated (larger) if draw data exceeds + capacity. +3. Embed ImGui shaders: + - Compile `imgui/shaders/imgui.vert` and `imgui/shaders/imgui.frag` + (from Dear ImGui's repo) to SPIR-V with `glslc`. + - `//go:embed` in `backend_vulkan.go`. +4. `func (b *VulkanBackend) RecordDraw(cmd vk.CommandBuffer, drawData + *imgui.DrawData, frameIndex uint32)`: + - Called inside the existing render pass (between video `Draw(3)` + and `EndRenderPass`). + - Map/`CopyToMapped` vertex + index data from `drawData` into the + staging buffers (or use `vk.Map`/`Unmap` on a host-visible + buffer). + - `cmd.BindPipeline(b.pipeline)` + - `cmd.BindDescriptorSet(b.layout, 0, b.set)` + - `cmd.BindVertexBuffers(...)` + - `cmd.BindIndexBuffer(...)` + - `cmd.SetViewport(...)` (full window) + - Push constants (scale/translate from drawData). + - For each draw list, for each command: `cmd.DrawIndexed(...)`. +5. Wire into `main.go` (or `renderer.DrawFrame`): + - After `cmd.Draw(3, 1, 0, 0)` (video), before `EndRenderPass`: + `imguiBackend.RecordDraw(cmd, drawData, imageIndex)`. + +Verify: add a test widget `imgui.Text("hello")` in `BeginFrame`/ +`EndFrame`. Run — you should see "hello" overlaid on the video. + +### M9.3 — The GUI: config panel + stats overlay + +Goal: usable GUI for entering connection params and showing stats. + +1. In `main.go`, between `imguiCtx.BeginFrame(...)` and + `imguiCtx.EndFrame()`, build the GUI: + - **Config panel** (window, shown by default): + ```go + imgui.Begin("Connection") + imgui.InputText("Domain", &domainBuf, 256) + imgui.InputText("Video UUID", &videoBuf, 256) + imgui.InputText("Audio UUID", &audioBuf, 256) + if imgui.Button("Connect") { + // trigger source.Open / OpenSync with the entered values + } + imgui.End() + ``` + - **Stats overlay** (window, toggled by F1, no title bar, no + background, top-left): + ```go + if showStats { + imgui.SetNextWindowPos(...) + imgui.Begin("Stats", &showStats, imgui.WindowFlagsNoTitleBar | imgui.WindowFlagsNoBackground) + imgui.Text(fmt.Sprintf("FPS: %.1f", fps)) + imgui.Text(fmt.Sprintf("Dropped: %d", dropped)) + imgui.Text(fmt.Sprintf("Index: %d", shownIndex)) + imgui.Text(fmt.Sprintf("Frame: %dx%d", videoW, videoH)) + imgui.Text(fmt.Sprintf("Format: v210 10-bit")) + imgui.End() + } + ``` + - **Dummy controls** for future M10 flow discovery: + ```go + imgui.Button("List Flows") // no-op yet + imgui.Button("Refresh") // no-op yet + ``` +2. State: `domainBuf`, `videoBuf`, `audioBuf` are `[256]byte` buffers + (ImGui's `InputText` needs a fixed buffer + capacity). Convert to + Go string on "Connect". +3. "Connect" button: + - Close existing source if any. + - Call `source.Open` / `source.OpenSync` with the buffer values. + - On error, display `imgui.Text` in red below the button. +4. F1 toggle: handle in the keydown event switch, flip `showStats`. + +Verify: type a domain + UUID, click Connect, video appears. Toggle +stats with F1. Resize window — GUI stays usable. + +### M9.4 — Wire GUI to engine + polish + +Goal: GUI controls the engine, not just displays. + +1. **Connection lifecycle**: "Connect" button triggers source open + + renderer buffer creation (if resolution changed). "Disconnect" + button closes source, video freezes on last frame (or clears to + black). +2. **Stats read from engine**: expose `Stats` struct from the loop + (fps, dropped, index, frameTime, resolution, format). GUI reads it + each frame. +3. **Freeze control** (placeholder for M10): `imgui.Checkbox("Freeze", + &frozen)`. When frozen, stop calling `DrawFrame` (keep last image + on screen, keep polling events + GUI). +4. **Input focus**: when ImGui wants keyboard input (text field + focused), don't pass key events to the app (e.g., don't toggle + fullscreen on 'F' while typing). Check + `imgui.GetIO().WantCaptureKeyboard`. +5. **Mouse capture**: when ImGui wants mouse, don't let the app + process mouse events. Check `imgui.GetIO().WantCaptureMouse`. +6. **DPI awareness**: scale ImGui font + style by the window's + `SDL_GetDisplayContentScale` (or hardcode 1.0 for now; polish + later). + +Verify: full workflow — launch app, enter params, connect, see video + +stats, toggle stats, disconnect, reconnect. All via GUI, no CLI flags +needed (though flags still work for headless/automation). + +## Notes + +- ImGui's vertex layout (`ImDrawVert`): `pos [2]float32, uv [2]float32, + col uint32` = 20 bytes. The vertex shader applies a scale/translate + push constant to convert from ImGui's screen coordinates to clip + space. +- ImGui's fragment shader samples the font texture (and any user + textures) using the UV from the vertex. Color is the vertex color, + multiplied by the texture sample. +- The backend pipeline uses alpha blending: + `src=SRC_ALPHA, dst=ONE_MINUS_SRC_ALPHA, op=ADD`. +- ImGui produces draw data *after* `EndFrame`/`Render`. The flow is: + `BeginFrame` → build widgets → `EndFrame`/`Render` → get draw data → + record Vulkan commands from draw data → submit. +- Font atlas upload is one-time, during `NewVulkanBackend`. Vertex/index + buffers are updated every frame from ImGui's draw data (host-visible, + persistently mapped). +- The ImGui pipeline is separate from the decode pipeline. Both render + into the same render pass / framebuffer / color attachment. diff --git a/imgui.ini b/imgui.ini new file mode 100644 index 0000000..54a2a14 --- /dev/null +++ b/imgui.ini @@ -0,0 +1,20 @@ +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][Test] +Pos=60,60 +Size=251,92 +Collapsed=0 + +[Window][Stats] +Pos=10,10 +Size=200,200 +Collapsed=0 + +[Window][Connection] +Pos=425,351 +Size=523,153 +Collapsed=0 + diff --git a/internal/imgui/backend_vulkan.go b/internal/imgui/backend_vulkan.go new file mode 100644 index 0000000..cef47da --- /dev/null +++ b/internal/imgui/backend_vulkan.go @@ -0,0 +1,301 @@ +package imgui + +import ( + "fmt" + "unsafe" + + cimgui "github.com/AllenDang/cimgui-go/imgui" + "github.com/christerso/vulkan-go/vk" +) + +// Renders ImGui draw data inside an existing Vulkan render pass +type VulkanBackend struct { + pd vk.PhysicalDevice + dev vk.Device + queue vk.Queue + cmdPool vk.CommandPool + + pipeline vk.Pipeline + pipelineLayout vk.PipelineLayout + descSetLayout vk.DescriptorSetLayout + descPool vk.DescriptorPool + descSet vk.DescriptorSet + vertMod vk.ShaderModule + fragMod vk.ShaderModule + + fontImg vk.AllocImage + fontView vk.ImageView + fontSampler vk.Sampler + + vertBuf vk.AllocBuffer + idxBuf vk.AllocBuffer + vertSize vk.DeviceSize + idxSize vk.DeviceSize +} + +// NewVulkanBackend creates the ImGui pipeline, font atlas, and buffers. +// rp is the existing render pass; format is the swapchain color format. +func NewVulkanBackend(pd vk.PhysicalDevice, dev vk.Device, queue vk.Queue, cmdPool vk.CommandPool, rp vk.RenderPass) (*VulkanBackend, error) { + b := &VulkanBackend{pd: pd, dev: dev, queue: queue, cmdPool: cmdPool} + + // 1. Font atlas — build and upload via CreateTexture2D (handles staging). + io := cimgui.CurrentIO() + fontAtlas := io.Fonts() + cimgui.InternalImFontAtlasBuildMain(fontAtlas) + texData := fontAtlas.TexData() + w, h := texData.Width(), texData.Height() + pixelCount := int(w) * int(h) * 4 // RGBA32 = 4 bytes/pixel + pixels := unsafe.Slice((*byte)(unsafe.Pointer(texData.Pixels())), pixelCount) + + var err error + b.fontImg, b.fontView, err = dev.CreateTexture2D(pd, queue, cmdPool, uint32(w), uint32(h), pixels) + if err != nil { + return nil, fmt.Errorf("imgui font atlas: %w", err) + } + + // Font sampler: linear, clamp to edge. + b.fontSampler, err = dev.CreateSampler(vk.SamplerConfig{ + MagFilter: vk.FilterLinear, + MinFilter: vk.FilterLinear, + AddressModeU: vk.SamplerAddressModeClampToEdge, + AddressModeV: vk.SamplerAddressModeClampToEdge, + }) + if err != nil { + b.destroyAll() + return nil, err + } + + // 2. Shaders. + b.vertMod, err = dev.CreateShaderModule(vertSPV) + if err != nil { + b.destroyAll() + return nil, err + } + b.fragMod, err = dev.CreateShaderModule(fragSPV) + if err != nil { + b.destroyAll() + return nil, err + } + + // 3. Descriptor set: one combined image sampler (font), fragment stage. + b.descSetLayout, err = dev.CreateDescriptorSetLayout([]vk.DescriptorBinding{ + {Binding: 0, Type: vk.DescriptorCombinedImageSampler, Count: 1, Stages: vk.ShaderStageFragment}, + }) + if err != nil { + b.destroyAll() + return nil, err + } + b.descPool, err = dev.CreateDescriptorPool(1, map[vk.DescriptorType]uint32{ + vk.DescriptorCombinedImageSampler: 1, + }) + if err != nil { + b.destroyAll() + return nil, err + } + b.descSet, err = dev.AllocateDescriptorSet(b.descPool, b.descSetLayout) + if err != nil { + b.destroyAll() + return nil, err + } + dev.UpdateImageDescriptor(b.descSet, 0, b.fontView, b.fontSampler) + + // 4. Pipeline layout: one set + 16 bytes push constants (vertex+fragment). + b.pipelineLayout, err = dev.CreatePipelineLayout( + []vk.DescriptorSetLayout{b.descSetLayout}, + vk.ShaderStageVertex|vk.ShaderStageFragment, 16, + ) + if err != nil { + b.destroyAll() + return nil, err + } + + // 5. Graphics pipeline: vertex input (pos+uv+col = 20 bytes), alpha blend. + b.pipeline, err = dev.CreateGraphicsPipeline(vk.GraphicsPipelineConfig{ + Layout: b.pipelineLayout, + RenderPass: rp, + VertexShader: b.vertMod, + FragShader: b.fragMod, + Bindings: []vk.VertexInputBinding{ + {Binding: 0, Stride: 20, InputRate: vk.VertexInputRateVertex}, + }, + Attributes: []vk.VertexInputAttribute{ + {Location: 0, Binding: 0, Format: vk.Format(103), Offset: 0}, // pos: R32G32Sfloat + {Location: 1, Binding: 0, Format: vk.Format(103), Offset: 8}, // uv: R32G32Sfloat + {Location: 2, Binding: 0, Format: vk.FormatR8G8B8A8Unorm, Offset: 16}, // col: R8G8B8A8Unorm + }, + Topology: vk.TopologyTriangleList, + PolygonMode: vk.PolygonFill, + CullMode: vk.CullNone, + FrontFace: vk.FrontFaceCounterClockwise, + Blend: true, + }) + if err != nil { + b.destroyAll() + return nil, err + } + + // 6. Vertex/index buffers: host-visible, mapped. Start at 64KB/16KB, grow if needed. + b.vertSize = 1 << 16 + b.idxSize = 1 << 14 + b.vertBuf, err = dev.CreateBuffer(pd, vk.BufferConfig{ + Size: b.vertSize, + Usage: vk.BufferUsageVertexBuffer, + Properties: vk.MemoryHostVisible | vk.MemoryHostCoherent, + Map: true, + }) + if err != nil { + b.destroyAll() + return nil, err + } + b.idxBuf, err = dev.CreateBuffer(pd, vk.BufferConfig{ + Size: vk.DeviceSize(b.idxSize), + Usage: vk.BufferUsageIndexBuffer, + Properties: vk.MemoryHostVisible | vk.MemoryHostCoherent, + Map: true, + }) + if err != nil { + b.destroyAll() + return nil, err + } + + return b, nil +} + +// RecordDraw records ImGui draw commands into the command buffer. +// Must be called inside the render pass, after your scene draw, before EndRenderPass. +func (b *VulkanBackend) RecordDraw(cmd vk.CommandBuffer, drawData *cimgui.DrawData) { + if !drawData.Valid() || drawData.CmdListsCount() == 0 { + return + } + + totalVtx := int(drawData.TotalVtxCount()) + totalIdx := int(drawData.TotalIdxCount()) + vertBytes := totalVtx * 20 + idxBytes := totalIdx * 2 + + // Grow vertex buffer if needed. + if vk.DeviceSize(vertBytes) > b.vertSize { + b.dev.DestroyBuffer(b.vertBuf) + b.vertSize = vk.DeviceSize(vertBytes) * 2 + b.vertBuf, _ = b.dev.CreateBuffer(b.pd, vk.BufferConfig{ + Size: b.vertSize, + Usage: vk.BufferUsageVertexBuffer, + Properties: vk.MemoryHostVisible | vk.MemoryHostCoherent, + Map: true, + }) + } + // Grow index buffer if needed. + if vk.DeviceSize(idxBytes) > vk.DeviceSize(b.idxBuf.Size) { + b.dev.DestroyBuffer(b.idxBuf) + b.idxBuf, _ = b.dev.CreateBuffer(b.pd, vk.BufferConfig{ + Size: vk.DeviceSize(idxBytes) * 2, + Usage: vk.BufferUsageIndexBuffer, + Properties: vk.MemoryHostVisible | vk.MemoryHostCoherent, + Map: true, + }) + } + + // Copy all vertex/index data into the mapped buffers. + vertOffset := 0 + idxOffset := 0 + cmdLists := drawData.CommandLists() + for _, list := range cmdLists { + // Vertices: GetVertexBuffer returns raw C pointer + byte size. + vtxPtr, vtxBytes2 := list.GetVertexBuffer() + if vtxBytes2 > 0 { + src := unsafe.Slice((*byte)(vtxPtr), vtxBytes2) + dst := unsafe.Slice((*byte)(b.vertBuf.Mapped), vertBytes) + copy(dst[vertOffset:], src) + vertOffset += vtxBytes2 + } + // Indices: GetIndexBuffer returns raw C pointer + byte size. + idxPtr, idxBytes2 := list.GetIndexBuffer() + if idxBytes2 > 0 { + src := unsafe.Slice((*byte)(idxPtr), idxBytes2) + dst := unsafe.Slice((*byte)(b.idxBuf.Mapped), idxBytes) + copy(dst[idxOffset:], src) + idxOffset += idxBytes2 + } + } + + // Push constants: scale + translate (transforms ImGui pixels to clip space). + displaySize := drawData.DisplaySize() + displayPos := drawData.DisplayPos() + scale := [2]float32{2.0 / displaySize.X, 2.0 / displaySize.Y} + translate := [2]float32{ + -1.0 - 2.0*displayPos.X/displaySize.X, + -1.0 - 2.0*displayPos.Y/displaySize.Y, + } + pc := struct { + Scale [2]float32 + Translate [2]float32 + }{ + Scale: scale, + Translate: translate, + } + cmd.PushConstants(b.pipelineLayout, vk.ShaderStageVertex|vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 16) + + // Bind pipeline + descriptor set + vertex/index buffers. + cmd.BindPipeline(b.pipeline) + cmd.BindDescriptorSet(b.pipelineLayout, 0, b.descSet) + offsets := []vk.DeviceSize{0} + cmd.BindVertexBuffers(0, []vk.Buffer{b.vertBuf.Buffer}, offsets) + cmd.BindIndexBuffer(b.idxBuf.Buffer, 0, vk.IndexTypeUint16) + + // Draw each command list, translating clip rects to scissors. + vtxOff := uint32(0) + idxOff := uint32(0) + for _, list := range cmdLists { + cmds := list.Commands() + for _, dc := range cmds { + if dc.HasUserCallback() { + dc.CallUserCallback(list) + continue + } + clip := dc.ClipRect() + sx := int32(clip.X - displayPos.X) + sy := int32(clip.Y - displayPos.Y) + ex := int32(clip.Z - displayPos.X) + ey := int32(clip.W - displayPos.Y) + if sx < 0 { + sx = 0 + } + if sy < 0 { + sy = 0 + } + if ex > int32(displaySize.X) { + ex = int32(displaySize.X) + } + if ey > int32(displaySize.Y) { + ey = int32(displaySize.Y) + } + if ex > sx && ey > sy { + cmd.SetScissor(vk.Rect2D{ + Offset: vk.Offset2D{X: sx, Y: sy}, + Extent: vk.Extent2D{Width: uint32(ex - sx), Height: uint32(ey - sy)}, + }) + } + cmd.DrawIndexed(dc.ElemCount(), 1, idxOff+dc.IdxOffset(), int32(vtxOff+dc.VtxOffset()), 0) + } + vtxOff += uint32(list.VtxBuffer().Size) + idxOff += uint32(list.IdxBuffer().Size) + } +} + +func (b *VulkanBackend) destroyAll() { + b.dev.DestroyBuffer(b.idxBuf) + b.dev.DestroyBuffer(b.vertBuf) + b.dev.DestroyPipeline(b.pipeline) + b.dev.DestroyPipelineLayout(b.pipelineLayout) + b.dev.DestroyDescriptorPool(b.descPool) + b.dev.DestroyDescriptorSetLayout(b.descSetLayout) + b.dev.DestroyShaderModule(b.fragMod) + b.dev.DestroyShaderModule(b.vertMod) + b.dev.DestroySampler(b.fontSampler) + b.dev.DestroyImageView(b.fontView) + b.dev.DestroyImage(b.fontImg) +} + +func (b *VulkanBackend) Destroy() { + b.destroyAll() +} diff --git a/internal/imgui/imgui.go b/internal/imgui/imgui.go new file mode 100644 index 0000000..7584645 --- /dev/null +++ b/internal/imgui/imgui.go @@ -0,0 +1,47 @@ +package imgui + +import ( + "time" + + cimgui "github.com/AllenDang/cimgui-go/imgui" +) + +// ImGui context & IO wrapper +type Context struct { + ctx *cimgui.Context + io *cimgui.IO + lastDrawData *cimgui.DrawData +} + +func New() *Context { + ctx := cimgui.CreateContext() + cimgui.SetCurrentContext(ctx) + io := cimgui.CurrentIO() + return &Context{ctx: ctx, io: io} +} + +func (c *Context) Destroy() { + cimgui.DestroyContext() +} + +// Starts new ImGui frame +// dt - time since last frame +func (c *Context) BeginFrame(dt time.Duration, winW, winH int32) { + c.io.SetDeltaTime(float32(dt.Seconds())) + c.io.SetDisplaySize(cimgui.Vec2{X: float32(winW), Y: float32(winH)}) + cimgui.NewFrame() +} + +// Ends the frame, renders +// and returns data for Vulkan +func (c *Context) EndFrame() *cimgui.DrawData { + cimgui.EndFrame() + cimgui.Render() + c.lastDrawData = cimgui.CurrentDrawData() + return cimgui.CurrentDrawData() +} + +// expose io to input backend +func (c *Context) IO() *cimgui.IO { return c.io } + +func (c *Context) LastDrawData() *cimgui.DrawData { return c.lastDrawData } diff --git a/internal/imgui/input_sdl3.go b/internal/imgui/input_sdl3.go new file mode 100644 index 0000000..cc63ee7 --- /dev/null +++ b/internal/imgui/input_sdl3.go @@ -0,0 +1,108 @@ +package imgui + +import ( + "mxl-player/internal/sdl" + "unsafe" + + cimgui "github.com/AllenDang/cimgui-go/imgui" +) + +// SDL3 event (128 byte raw buffer) -> imgui +func (c *Context) ProcessEvent(event *[128]byte) { + eventType := *(*uint32)(unsafe.Pointer(&event[0])) + + // CommonEvent (16 bytes: type uint32 + reserved uint32 + timestamp uint64) + switch eventType { + case sdl.EventKeyDown, sdl.EventKeyUp: + scancode := *(*uint32)(unsafe.Pointer(&event[24])) + down := eventType == sdl.EventKeyDown + key := sdlScancodeToImGuiKey(scancode) + if key >= 0 { + c.io.AddKeyEvent(key, down) + } + // MouseMotionEvent: +16 windowID(4), +20 which(4), +24 state(4), +28 x(float32), +32 y(float32) + case sdl.EventMouseMotion: + x := *(*float32)(unsafe.Pointer(&event[28])) + y := *(*float32)(unsafe.Pointer(&event[32])) + c.io.AddMousePosEvent(x, y) + // MouseButtonEvent: +16 windowID(4), +20 which(4), +24 button(1), +25 down(bool) + case sdl.EventMouseButtonDown, sdl.EventMouseButtonUp: + button := *(*uint8)(unsafe.Pointer(&event[24])) + down := eventType == sdl.EventMouseButtonDown + c.io.AddMouseButtonEvent(int32(button-1), down) + // MouseWheelEvent: +16 windowID(4), +20 which(4), +24 x(float32), +28 y(float32) + case sdl.EventMouseWheel: + x := *(*float32)(unsafe.Pointer(&event[24])) + y := *(*float32)(unsafe.Pointer(&event[28])) + c.io.AddMouseWheelEvent(x, y) + // TextInputEvent: +16 windowID(4), +20 text(*char pointer, 8 bytes on 64-bit) + case sdl.EventTextInput: + // text pointer is at offset 24, 8 bytes (pointer) + ptr := *(*uintptr)(unsafe.Pointer(&event[24])) + if ptr != 0 { + text := cstr(ptr) + c.io.AddInputCharactersUTF8(text) + } + + } +} + +func sdlScancodeToImGuiKey(scancode uint32) cimgui.Key { + switch scancode { + case 40: // SDL_SCANCODE_RETURN + return cimgui.KeyEnter + case 42: // SDL_SCANCODE_BACKSPACE + return cimgui.KeyBackspace + case 41: // SDL_SCANCODE_ESCAPE + return cimgui.KeyEscape + case 43: // SDL_SCANCODE_TAB + return cimgui.KeyTab + case 44: // SDL_SCANCODE_SPACE + return cimgui.KeySpace + case 80: // SDL_SCANCODE_LEFT + return cimgui.KeyLeftArrow + case 79: // SDL_SCANCODE_RIGHT + return cimgui.KeyRightArrow + case 82: // SDL_SCANCODE_UP + return cimgui.KeyUpArrow + case 81: // SDL_SCANCODE_DOWN + return cimgui.KeyDownArrow + case 225: // SDL_SCANCODE_LSHIFT + return cimgui.KeyLeftShift + case 229: // SDL_SCANCODE_RSHIFT + return cimgui.KeyRightShift + case 224: // SDL_SCANCODE_LCTRL + return cimgui.KeyLeftCtrl + case 228: // SDL_SCANCODE_RCTRL + return cimgui.KeyRightCtrl + case 226: // SDL_SCANCODE_LALT + return cimgui.KeyLeftAlt + case 230: // SDL_SCANCODE_RALT + return cimgui.KeyRightAlt + default: + // Letters A-Z: scancodes 4-29 map to ImGui KeyA-KeyZ + if scancode >= 4 && scancode <= 29 { + return cimgui.Key(int(cimgui.KeyA) + int(scancode-4)) + } + // Numbers 0-9: scancodes 30-39 + if scancode >= 30 && scancode <= 39 { + return cimgui.Key(int(cimgui.Key0) + int(scancode-30)) + } + return -1 + } +} + +// cstr reads a NUL-terminated C string at p. +// TODO: same func used in Vulkan +// Maybe i should create something like "package common" +// for funcs like this +func cstr(p uintptr) string { + if p == 0 { + return "" + } + var n int + for *(*byte)(unsafe.Pointer(p + uintptr(n))) != 0 { + n++ + } + return string(unsafe.Slice((*byte)(unsafe.Pointer(p)), n)) +} diff --git a/internal/imgui/shaders.go b/internal/imgui/shaders.go new file mode 100644 index 0000000..b0d59e3 --- /dev/null +++ b/internal/imgui/shaders.go @@ -0,0 +1,9 @@ +package imgui + +import _ "embed" + +//go:embed shaders/imgui.vert.spv +var vertSPV []byte + +//go:embed shaders/imgui.frag.spv +var fragSPV []byte diff --git a/internal/imgui/shaders/imgui.frag b/internal/imgui/shaders/imgui.frag new file mode 100644 index 0000000..ce7e6f7 --- /dev/null +++ b/internal/imgui/shaders/imgui.frag @@ -0,0 +1,14 @@ +#version 450 core +layout(location = 0) out vec4 fColor; + +layout(set=0, binding=0) uniform sampler2D sTexture; + +layout(location = 0) in struct { + vec4 Color; + vec2 UV; +} In; + +void main() +{ + fColor = In.Color * texture(sTexture, In.UV.st); +} diff --git a/internal/imgui/shaders/imgui.frag.spv b/internal/imgui/shaders/imgui.frag.spv new file mode 100644 index 0000000..87b9aea Binary files /dev/null and b/internal/imgui/shaders/imgui.frag.spv differ diff --git a/internal/imgui/shaders/imgui.vert b/internal/imgui/shaders/imgui.vert new file mode 100644 index 0000000..9425365 --- /dev/null +++ b/internal/imgui/shaders/imgui.vert @@ -0,0 +1,25 @@ +#version 450 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec4 aColor; + +layout(push_constant) uniform uPushConstant { + vec2 uScale; + vec2 uTranslate; +} pc; + +out gl_PerVertex { + vec4 gl_Position; +}; + +layout(location = 0) out struct { + vec4 Color; + vec2 UV; +} Out; + +void main() +{ + Out.Color = aColor; + Out.UV = aUV; + gl_Position = vec4(aPos * pc.uScale + pc.uTranslate, 0, 1); +} diff --git a/internal/imgui/shaders/imgui.vert.spv b/internal/imgui/shaders/imgui.vert.spv new file mode 100644 index 0000000..14bcce0 Binary files /dev/null and b/internal/imgui/shaders/imgui.vert.spv differ diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index 1458c58..8dbbefe 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -59,6 +59,9 @@ type Renderer struct { imageAvailable vk.Semaphore renderFinished vk.Semaphore inFlight vk.Fence + + // Called inside render pass, after video draw + ImGuiDraw func(cmd vk.CommandBuffer) } // public params, needed to create a Renderer @@ -441,6 +444,9 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { } cmd.PushConstants(r.decodeLayout, vk.ShaderStageFragment, 0, unsafe.Pointer(&pc), 20) cmd.Draw(3, 1, 0, 0) + if r.ImGuiDraw != nil { + r.ImGuiDraw(cmd) + } cmd.EndRenderPass() if err := cmd.End(); err != nil { return err @@ -465,3 +471,6 @@ func (r *Renderer) DrawFrame(videoW, videoH, stride uint32) error { } return nil } + +func (r *Renderer) CmdPool() vk.CommandPool { return r.cmdPool } +func (r *Renderer) RenderPass() vk.RenderPass { return r.renderPass } diff --git a/internal/sdl/sdl.go b/internal/sdl/sdl.go index bcbdb41..6e50ba1 100644 --- a/internal/sdl/sdl.go +++ b/internal/sdl/sdl.go @@ -16,10 +16,22 @@ const ( EventQuit uint32 = 0x100 EventWindowResized uint32 = 0x206 EventPixelSizeChanged uint32 = 0x207 - EventKeyDown uint32 = 0x300 + EventKeyDown uint32 = 0x300 + EventKeyUp uint32 = 0x301 + + EventMouseMotion uint32 = 0x400 + EventMouseButtonDown uint32 = 0x401 + EventMouseButtonUp uint32 = 0x402 + EventMouseWheel uint32 = 0x403 + + EventTextInput uint32 = 0x303 + + // it's about SDL_keycode, not SDL_scancode KeyEscape uint32 = 0x1B KeyF uint32 = 0x66 + KeyQ uint32 = 0x71 + KeyF1 uint32 = 0x4000003A InitAudio uint32 = 0x00000010 AudioDeviceDefaultPlayback uint32 = 0xFFFFFFFF @@ -49,6 +61,9 @@ var ( sdlDestroyAudioStream func(stream uintptr) sdlGetAudioPlaybackDevices func(count *int32) uintptr sdlGetAudioDeviceName func(devid uint32) uintptr + + sdlStartTextInput func(window uintptr) + sdlStopTextInput func(window uintptr) ) var loaded = false @@ -79,6 +94,9 @@ func Load() error { purego.RegisterLibFunc(&sdlDestroyAudioStream, h, "SDL_DestroyAudioStream") purego.RegisterLibFunc(&sdlGetAudioPlaybackDevices, h, "SDL_GetAudioPlaybackDevices") purego.RegisterLibFunc(&sdlGetAudioDeviceName, h, "SDL_GetAudioDeviceName") + // input + purego.RegisterLibFunc(&sdlStartTextInput, h, "SDL_StartTextInput") + purego.RegisterLibFunc(&sdlStopTextInput, h, "SDL_StopTextInput") loaded = true return nil } @@ -174,3 +192,7 @@ func GetAudioPlaybackDevices() []AudioDevice { } return devs } + +// Input wrappers +func StartTextInput(window uintptr) { sdlStartTextInput(window) } +func StopTextInput(window uintptr) { sdlStopTextInput(window) } diff --git a/internal/source/source.go b/internal/source/source.go index beb0f1e..d3a4aca 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -403,6 +403,7 @@ func (s *SyncSource) Close() error { // NextSync reads both at a synced timestamp. Returns video Frame + audio AudioFrame func (s *SyncSource) NextSync(ctx context.Context, audioBatch uint64, timeout time.Duration) (Frame, AudioFrame, error) { + var timeouts int for { select { case <-ctx.Done(): @@ -421,7 +422,19 @@ func (s *SyncSource) NextSync(ctx context.Context, audioBatch uint64, timeout ti s.idx = mxl.CurrentIndex(s.rate) continue } - return Frame{}, AudioFrame{}, fmt.Errorf("GetGraing: %w", gerr) + if errors.Is(gerr, mxl.ErrOutOfRangeLate) { + s.idx = mxl.CurrentIndex(s.rate) + continue + } + if errors.Is(gerr, mxl.ErrOutOfRangeEarly) { + select { + case <-time.After(5 * time.Millisecond): + case <-ctx.Done(): + return Frame{}, AudioFrame{}, ctx.Err() + } + continue + } + return Frame{}, AudioFrame{}, fmt.Errorf("GetGrain: %w", gerr) } // read audio at the same timestamp aIdx := mxl.TimestampToIndex(s.aRate, ts) @@ -451,14 +464,19 @@ func (s *SyncSource) NextSync(ctx context.Context, audioBatch uint64, timeout ti } // even if audio failed, video returns return vFrame, aFrame, nil - case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly): + case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly), errors.Is(err, mxl.ErrOutOfRangeLate): + timeouts++ + if timeouts > 10 { + timeouts = 0 + s.idx = mxl.CurrentIndex(s.rate) + return Frame{}, AudioFrame{}, fmt.Errorf("sync: feeds not responding") + } + s.idx = mxl.CurrentIndex(s.rate) select { case <-time.After(5 * time.Millisecond): case <-ctx.Done(): return Frame{}, AudioFrame{}, ctx.Err() } - case errors.Is(err, mxl.ErrOutOfRangeLate): - s.idx = mxl.CurrentIndex(s.rate) default: return Frame{}, AudioFrame{}, fmt.Errorf("WaitForDataAt: %w", err) } diff --git a/internal/test.go b/internal/test.go new file mode 100644 index 0000000..6b4b9fa --- /dev/null +++ b/internal/test.go @@ -0,0 +1,7 @@ +package main + +import "mxl-player/internal/imgui" + +func main() { + imgui.New() +} diff --git a/mxl-gst-scripts/imgui.ini b/mxl-gst-scripts/imgui.ini new file mode 100644 index 0000000..a2621ef --- /dev/null +++ b/mxl-gst-scripts/imgui.ini @@ -0,0 +1,15 @@ +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][Stats] +Pos=10,10 +Size=200,200 +Collapsed=0 + +[Window][Connection] +Pos=60,60 +Size=110,146 +Collapsed=0 + diff --git a/mxl-gst-scripts/loop-feed2.sh b/mxl-gst-scripts/loop-feed2.sh new file mode 100755 index 0000000..f5ac2c3 --- /dev/null +++ b/mxl-gst-scripts/loop-feed2.sh @@ -0,0 +1,12 @@ +#!/bin/bash +VIDEO_ID="5fbec3b1-1b0f-417d-9059-8b94a47197ef" +AUDIO_ID="5fbec3b1-1b0f-417d-9059-8b94a47197eb" +VIDEO_URI=$1 +if [[ -z "${VIDEO_URI}" ]] then + VIDEO_URI="${HOME}/Videos/test-vid/f1.ts" +fi +export GST_PLUGIN_PATH="${HOME}/.gst-plugin:${GST_PLUGIN_PATH}" +mxl-gst-looping-filesrc -d /dev/shm/mxl -i "${VIDEO_URI}" --video-id "${VIDEO_ID}" --audio-id "${AUDIO_ID}" 2>/dev/null +# sleep 5 +# kill -9 $(pidof "mxl-gst-looping-filesrc") +# echo -e "\ngst-looping-filesrc killed" diff --git a/mxl-gst-scripts/test-reconnect.sh b/mxl-gst-scripts/test-reconnect.sh new file mode 100755 index 0000000..cadaf93 --- /dev/null +++ b/mxl-gst-scripts/test-reconnect.sh @@ -0,0 +1,20 @@ +#!/bin/bash +#!/bin/bash +VIDEO_ID="5fbec3b1-1b0f-417d-9059-8b94a47197ef" +AUDIO_ID="5fbec3b1-1b0f-417d-9059-8b94a47197eb" +VIDEO_URI=$1 +if [[ -z "${VIDEO_URI}" ]] then + VIDEO_URI="${HOME}/Videos/test-vid/f1.ts" +fi +export GST_PLUGIN_PATH="${HOME}/.gst-plugin:${GST_PLUGIN_PATH}" +mxl-gst-looping-filesrc -d /dev/shm/mxl -i "${VIDEO_URI}" --video-id "${VIDEO_ID}" --audio-id "${AUDIO_ID}" 2>/dev/null & +sleep 1 +echo "Writer has been started" +go run ../cmd/mxl-player -d /dev/shm/mxl -v "${VIDEO_ID}" -a "${AUDIO_ID}" &> /tmp/player.log & +echo "mxl-player has been started" +sleep 5 +pkill mxl-gst +echo "Writer stopped" >> /tmp/player.log +sleep 5 +echo "Writer has been started again" >> /tmp/player.log +mxl-gst-looping-filesrc -d /dev/shm/mxl -i "${VIDEO_URI}" --video-id "${VIDEO_ID}" --audio-id "${AUDIO_ID}" 2>/dev/null diff --git a/mxl-player b/mxl-player new file mode 100755 index 0000000..54440f8 Binary files /dev/null and b/mxl-player differ