From bc0b4f7ad345d681c5d2d7bfc5a2ebb9a50dce13 Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Wed, 7 Jan 2026 02:50:27 -0500 Subject: [PATCH] Add GStreamer preview backend --- internal/player/frame_player.go | 18 ++ internal/player/frame_player_default.go | 7 + internal/player/frame_player_gstreamer.go | 10 + internal/player/gstreamer_player.go | 332 ++++++++++++++++++++++ internal/player/unified_player_adapter.go | 33 +-- scripts/build-linux.sh | 11 + scripts/build.sh | 3 + 7 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 internal/player/frame_player.go create mode 100644 internal/player/frame_player_default.go create mode 100644 internal/player/frame_player_gstreamer.go create mode 100644 internal/player/gstreamer_player.go diff --git a/internal/player/frame_player.go b/internal/player/frame_player.go new file mode 100644 index 0000000..8bf4120 --- /dev/null +++ b/internal/player/frame_player.go @@ -0,0 +1,18 @@ +package player + +import ( + "image" + "time" +) + +type framePlayer interface { + Load(path string, offset time.Duration) error + Play() error + Pause() error + SeekToTime(offset time.Duration) error + SeekToFrame(frame int64) error + GetCurrentTime() time.Duration + GetFrameImage() (*image.RGBA, error) + SetVolume(level float64) error + Close() +} diff --git a/internal/player/frame_player_default.go b/internal/player/frame_player_default.go new file mode 100644 index 0000000..cd96196 --- /dev/null +++ b/internal/player/frame_player_default.go @@ -0,0 +1,7 @@ +//go:build !gstreamer + +package player + +func newFramePlayer(config Config) (framePlayer, error) { + return NewUnifiedPlayer(config), nil +} diff --git a/internal/player/frame_player_gstreamer.go b/internal/player/frame_player_gstreamer.go new file mode 100644 index 0000000..ee16950 --- /dev/null +++ b/internal/player/frame_player_gstreamer.go @@ -0,0 +1,10 @@ +//go:build gstreamer + +package player + +func newFramePlayer(config Config) (framePlayer, error) { + if gstPlayer, err := NewGStreamerPlayer(config); err == nil { + return gstPlayer, nil + } + return NewUnifiedPlayer(config), nil +} diff --git a/internal/player/gstreamer_player.go b/internal/player/gstreamer_player.go new file mode 100644 index 0000000..25a06ee --- /dev/null +++ b/internal/player/gstreamer_player.go @@ -0,0 +1,332 @@ +//go:build gstreamer + +package player + +/* +#cgo pkg-config: gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 +#include +#include +#include +#include + +static void vt_gst_set_str(GstElement* elem, const char* name, const char* value) { + g_object_set(G_OBJECT(elem), name, value, NULL); +} +static void vt_gst_set_bool(GstElement* elem, const char* name, gboolean value) { + g_object_set(G_OBJECT(elem), name, value, NULL); +} +static void vt_gst_set_int(GstElement* elem, const char* name, gint value) { + g_object_set(G_OBJECT(elem), name, value, NULL); +} +static void vt_gst_set_float(GstElement* elem, const char* name, gdouble value) { + g_object_set(G_OBJECT(elem), name, value, NULL); +} +static void vt_gst_set_obj(GstElement* elem, const char* name, gpointer value) { + g_object_set(G_OBJECT(elem), name, value, NULL); +} +*/ +import "C" + +import ( + "errors" + "image" + "net/url" + "path/filepath" + "runtime" + "sync" + "time" + "unsafe" +) + +var gstInitOnce sync.Once + +type GStreamerPlayer struct { + mu sync.Mutex + pipeline *C.GstElement + appsink *C.GstElement + paused bool + volume float64 + preview bool + width int + height int + fps float64 +} + +func NewGStreamerPlayer(config Config) (*GStreamerPlayer, error) { + var initErr error + gstInitOnce.Do(func() { + if C.gst_init_check(nil, nil, nil) == 0 { + initErr = errors.New("gstreamer init failed") + } + }) + if initErr != nil { + return nil, initErr + } + + return &GStreamerPlayer{ + paused: true, + volume: config.Volume, + preview: config.PreviewMode, + }, nil +} + +func (p *GStreamerPlayer) Load(path string, offset time.Duration) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.closeLocked() + + playbinName := C.CString("playbin") + playbin := C.gst_element_factory_make(playbinName, nil) + C.free(unsafe.Pointer(playbinName)) + if playbin == nil { + return errors.New("gstreamer playbin unavailable") + } + + appsinkName := C.CString("appsink") + appsink := C.gst_element_factory_make(appsinkName, nil) + C.free(unsafe.Pointer(appsinkName)) + if appsink == nil { + C.gst_object_unref(C.gpointer(playbin)) + return errors.New("gstreamer appsink unavailable") + } + + capsStr := C.CString("video/x-raw,format=RGBA") + caps := C.gst_caps_from_string(capsStr) + C.free(unsafe.Pointer(capsStr)) + if caps != nil { + capsName := C.CString("caps") + C.vt_gst_set_obj(appsink, capsName, C.gpointer(caps)) + C.free(unsafe.Pointer(capsName)) + C.gst_caps_unref(caps) + } + emitSignals := C.CString("emit-signals") + C.vt_gst_set_bool(appsink, emitSignals, C.gboolean(0)) + C.free(unsafe.Pointer(emitSignals)) + syncName := C.CString("sync") + C.vt_gst_set_bool(appsink, syncName, C.gboolean(0)) + C.free(unsafe.Pointer(syncName)) + maxBuffers := C.CString("max-buffers") + C.vt_gst_set_int(appsink, maxBuffers, C.gint(2)) + C.free(unsafe.Pointer(maxBuffers)) + dropName := C.CString("drop") + C.vt_gst_set_bool(appsink, dropName, C.gboolean(1)) + C.free(unsafe.Pointer(dropName)) + + var audioSink *C.GstElement + if p.preview { + fakeName := C.CString("fakesink") + audioSink = C.gst_element_factory_make(fakeName, nil) + C.free(unsafe.Pointer(fakeName)) + } else { + autoName := C.CString("autoaudiosink") + audioSink = C.gst_element_factory_make(autoName, nil) + C.free(unsafe.Pointer(autoName)) + } + if audioSink == nil { + C.gst_object_unref(C.gpointer(playbin)) + C.gst_object_unref(C.gpointer(appsink)) + return errors.New("gstreamer audio sink unavailable") + } + + uri := fileURI(path) + uriC := C.CString(uri) + uriName := C.CString("uri") + C.vt_gst_set_str(playbin, uriName, uriC) + C.free(unsafe.Pointer(uriName)) + C.free(unsafe.Pointer(uriC)) + videoSinkName := C.CString("video-sink") + C.vt_gst_set_obj(playbin, videoSinkName, C.gpointer(appsink)) + C.free(unsafe.Pointer(videoSinkName)) + audioSinkName := C.CString("audio-sink") + C.vt_gst_set_obj(playbin, audioSinkName, C.gpointer(audioSink)) + C.free(unsafe.Pointer(audioSinkName)) + + if p.volume <= 0 { + p.volume = 1.0 + } + volumeName := C.CString("volume") + C.vt_gst_set_float(playbin, volumeName, C.gdouble(p.volume)) + C.free(unsafe.Pointer(volumeName)) + + p.pipeline = playbin + p.appsink = appsink + p.paused = true + + C.gst_element_set_state(playbin, C.GST_STATE_PAUSED) + if offset > 0 { + _ = p.seekLocked(offset) + } + + return nil +} + +func (p *GStreamerPlayer) Play() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.pipeline == nil { + return errors.New("no pipeline loaded") + } + C.gst_element_set_state(p.pipeline, C.GST_STATE_PLAYING) + p.paused = false + return nil +} + +func (p *GStreamerPlayer) Pause() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.pipeline == nil { + return errors.New("no pipeline loaded") + } + C.gst_element_set_state(p.pipeline, C.GST_STATE_PAUSED) + p.paused = true + return nil +} + +func (p *GStreamerPlayer) SeekToTime(offset time.Duration) error { + p.mu.Lock() + defer p.mu.Unlock() + return p.seekLocked(offset) +} + +func (p *GStreamerPlayer) seekLocked(offset time.Duration) error { + if p.pipeline == nil { + return errors.New("no pipeline loaded") + } + nanos := C.gint64(offset.Nanoseconds()) + flags := C.GST_SEEK_FLAG_FLUSH | C.GST_SEEK_FLAG_KEY_UNIT + if C.gst_element_seek_simple(p.pipeline, C.GST_FORMAT_TIME, flags, nanos) == 0 { + return errors.New("gstreamer seek failed") + } + return nil +} + +func (p *GStreamerPlayer) SeekToFrame(frame int64) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.fps <= 0 { + return nil + } + seconds := float64(frame) / p.fps + return p.seekLocked(time.Duration(seconds * float64(time.Second))) +} + +func (p *GStreamerPlayer) GetCurrentTime() time.Duration { + p.mu.Lock() + defer p.mu.Unlock() + if p.pipeline == nil { + return 0 + } + var pos C.gint64 + if C.gst_element_query_position(p.pipeline, C.GST_FORMAT_TIME, &pos) == 0 { + return 0 + } + return time.Duration(pos) +} + +func (p *GStreamerPlayer) GetFrameImage() (*image.RGBA, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.appsink == nil { + return nil, errors.New("gstreamer appsink unavailable") + } + sample := C.gst_app_sink_try_pull_sample((*C.GstAppSink)(unsafe.Pointer(p.appsink)), 0) + if sample == nil { + return nil, nil + } + defer C.gst_sample_unref(sample) + + caps := C.gst_sample_get_caps(sample) + if caps == nil { + return nil, errors.New("gstreamer caps unavailable") + } + str := C.gst_caps_get_structure(caps, 0) + var width C.gint + var height C.gint + widthName := C.CString("width") + C.gst_structure_get_int(str, widthName, &width) + C.free(unsafe.Pointer(widthName)) + heightName := C.CString("height") + C.gst_structure_get_int(str, heightName, &height) + C.free(unsafe.Pointer(heightName)) + if width > 0 && height > 0 { + p.width = int(width) + p.height = int(height) + } + var fpsNum C.gint + var fpsDen C.gint + fpsName := C.CString("framerate") + if C.gst_structure_get_fraction(str, fpsName, &fpsNum, &fpsDen) != 0 && fpsDen != 0 { + p.fps = float64(fpsNum) / float64(fpsDen) + } + C.free(unsafe.Pointer(fpsName)) + + buffer := C.gst_sample_get_buffer(sample) + if buffer == nil { + return nil, errors.New("gstreamer buffer unavailable") + } + var mapInfo C.GstMapInfo + if C.gst_buffer_map(buffer, &mapInfo, C.GST_MAP_READ) == 0 { + return nil, errors.New("gstreamer buffer map failed") + } + defer C.gst_buffer_unmap(buffer, &mapInfo) + + if p.width == 0 || p.height == 0 { + return nil, errors.New("invalid frame size") + } + frameSize := p.width * p.height * 4 + if int(mapInfo.size) < frameSize { + return nil, errors.New("incomplete frame") + } + + img := image.NewRGBA(image.Rect(0, 0, p.width, p.height)) + data := unsafe.Slice((*byte)(unsafe.Pointer(mapInfo.data)), frameSize) + copy(img.Pix, data) + return img, nil +} + +func (p *GStreamerPlayer) SetVolume(level float64) error { + p.mu.Lock() + defer p.mu.Unlock() + p.volume = level + if p.pipeline != nil { + volumeName := C.CString("volume") + C.vt_gst_set_float(p.pipeline, volumeName, C.gdouble(level)) + C.free(unsafe.Pointer(volumeName)) + } + return nil +} + +func (p *GStreamerPlayer) Close() { + p.mu.Lock() + defer p.mu.Unlock() + p.closeLocked() +} + +func (p *GStreamerPlayer) closeLocked() { + if p.pipeline != nil { + C.gst_element_set_state(p.pipeline, C.GST_STATE_NULL) + C.gst_object_unref(C.gpointer(p.pipeline)) + p.pipeline = nil + } + if p.appsink != nil { + C.gst_object_unref(C.gpointer(p.appsink)) + p.appsink = nil + } +} + +func fileURI(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + abs = filepath.ToSlash(abs) + if runtime.GOOS == "windows" && len(abs) >= 2 && abs[1] == ':' { + abs = "/" + abs + } + u := url.URL{Scheme: "file", Path: abs} + return u.String() +} diff --git a/internal/player/unified_player_adapter.go b/internal/player/unified_player_adapter.go index e09afa3..2afd54e 100644 --- a/internal/player/unified_player_adapter.go +++ b/internal/player/unified_player_adapter.go @@ -13,8 +13,8 @@ import ( // UnifiedPlayerAdapter wraps UnifiedPlayer to provide playSession interface compatibility // This allows seamless replacement of the dual-process player with UnifiedPlayer type UnifiedPlayerAdapter struct { - // Core UnifiedPlayer - player *UnifiedPlayer + // Frame-capable player backend + player framePlayer // Interface compatibility fields (from playSession) path string @@ -64,7 +64,7 @@ func NewUnifiedPlayerAdapter(path string, width, height int, fps, duration float startTime: time.Now(), } - // Create UnifiedPlayer with proper configuration + // Create frame-capable player with proper configuration config := Config{ Backend: BackendAuto, // Use auto for UnifiedPlayer WindowX: 0, @@ -83,23 +83,10 @@ func NewUnifiedPlayerAdapter(path string, width, height int, fps, duration float LogLevel: 3, // Debug } - adapter.player = NewUnifiedPlayer(config) - - // Set up callbacks for progress and frame updates - adapter.player.SetTimeCallback(func(d time.Duration) { - seconds := d.Seconds() - adapter.current = seconds - if adapter.prog != nil { - adapter.prog(seconds) - } - }) - - adapter.player.SetFrameCallback(func(frame int64) { - adapter.frameN = int(frame) - if adapter.frameFunc != nil { - adapter.frameFunc(int(frame)) - } - }) + playerBackend, err := newFramePlayer(config) + if err == nil { + adapter.player = playerBackend + } return adapter } @@ -328,7 +315,11 @@ func (p *UnifiedPlayerAdapter) startFrameDisplayLoop() { go func() { // Display at frame rate - frameDuration := time.Second / time.Duration(p.fps) + fps := p.fps + if fps <= 0 { + fps = 24 + } + frameDuration := time.Second / time.Duration(fps) ticker := time.NewTicker(frameDuration) defer ticker.Stop() diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 3327f95..49570c7 100755 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -58,6 +58,17 @@ mkdir -p "$GOCACHE" "$GOMODCACHE" if [ -d "$PROJECT_ROOT/vendor" ] && [ ! -f "$PROJECT_ROOT/vendor/modules.txt" ]; then export GOFLAGS="${GOFLAGS:-} -mod=mod" fi +GST_TAG="" +if [ -n "$VT_GSTREAMER" ]; then + GST_TAG="gstreamer" +elif command -v pkg-config &> /dev/null; then + if pkg-config --exists gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0; then + GST_TAG="gstreamer" + fi +fi +if [ -n "$GST_TAG" ]; then + export GOFLAGS="${GOFLAGS:-} -tags ${GST_TAG}" +fi if go build -o "$BUILD_OUTPUT" .; then echo "Build successful! (VideoTools $APP_VERSION)" echo "" diff --git a/scripts/build.sh b/scripts/build.sh index 8cca5c7..0c30b51 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -64,6 +64,9 @@ case "$OS" in echo "Building VideoTools $APP_VERSION for Windows..." export CGO_ENABLED=1 + if [ -n "$VT_GSTREAMER" ] || command -v gst-launch-1.0 &> /dev/null; then + export GOFLAGS="${GOFLAGS:-} -tags gstreamer" + fi if [ -d "$PROJECT_ROOT/vendor" ] && [ ! -f "$PROJECT_ROOT/vendor/modules.txt" ]; then export GOFLAGS="${GOFLAGS:-} -mod=mod" fi