Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Latest pipeline run: [![Go](https://github.com/bugbundle/phantom/actions/workflows/go.yml/badge.svg)](https://github.com/bugbundle/phantom/actions/workflows/go.yml)

# phantom

Stream video from usb device over the web.
Expand Down
3 changes: 2 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import (
)

func main() {
http.Server("0.0.0.0:8080")
svc := http.NewService("127.0.0.1:8080")
svc.Run()
}
8 changes: 8 additions & 0 deletions internal/adapter/camera/camera.go
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
package camera

import "gocv.io/x/gocv"

type CameraService interface {
Open(deviceId int) error
Close() error
CaptureImage() (*gocv.Mat, error)
}
Original file line number Diff line number Diff line change
@@ -1,76 +1,63 @@
package http
package controls

import (
"fmt"
"html/template"
"image"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"strconv"
"time"

"github.com/bugbundle/phantom/internal/port/camera"
"gocv.io/x/gocv"
)

// Return default Homepage, a simple alpineJS application to users
func Homepage(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/html")
template, err := template.ParseFiles("templates/index.html.tpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := template.Execute(w, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
func RegisterRoutes(router *http.ServeMux) {
router.HandleFunc("POST /cameras/{deviceId}", startCameraHandler)
router.HandleFunc("DELETE /cameras", stopCameraHandler)
router.HandleFunc("GET /cameras", StreamVideoHandler)
}

func StreamStatus(w http.ResponseWriter, r *http.Request) {
// If the camera is unavailable return 428
_, err := camera.GetCamera()
// If not yet, create a camera entity
func startCameraHandler(w http.ResponseWriter, r *http.Request) {
g, err := strconv.Atoi(r.PathValue("deviceId"))
if err != nil {
w.Write([]byte("false"))
return
http.Error(w, "Invalid deviceId", http.StatusNotFound)
}
w.Write([]byte("true"))
// Trigger singleton to instanciate camera
device := camera.GetInstance()
device.Open(g)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
}

// If existing, remove a camera entity
func stopCameraHandler(w http.ResponseWriter, r *http.Request) {
device := camera.GetInstance()
device.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
}

// This function retrieve camera device and start streaming using multipart/x-mixed-replace
// TODO: Add device number option
func StreamVideo(w http.ResponseWriter, r *http.Request) {
func StreamVideoHandler(w http.ResponseWriter, r *http.Request) {
// If the camera is unavailable return 428
webcam, err := camera.GetCamera()
if err != nil {
w.Header().Set("Content-Type", "application/json")
http.Error(w, "{\"reason\": \"webcam is not started\"}", http.StatusPreconditionRequired)
return
}

// Try to read the webcam
openErr := webcam.Open()
if openErr != nil {
log.Println("Got the following error:", openErr)
}
webcam := camera.GetInstance()

mimeWriter := multipart.NewWriter(w)
w.Header().Set("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=%s", mimeWriter.Boundary()))
partHeader := make(textproto.MIMEHeader)
partHeader.Add("Content-Type", "image/jpeg")

for {
if !webcam.IsOpen() {
http.Error(w, "Missing camera", http.StatusServiceUnavailable)
return
}

img, err := webcam.CaptureImage()
if err != nil {
http.Error(w, "Error capturing image: %v", http.StatusServiceUnavailable)
}
defer img.Close()
// defer img.Close()

resizedFrame := gocv.NewMat()

Expand All @@ -94,20 +81,3 @@ func StreamVideo(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
}
}

// Create Camera using POST request
// TODO: Add device number option
func CreateCamera(w http.ResponseWriter, r *http.Request) {
// Trigger singleton to instanciate camera
camera.CreateOrGetCamera()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
}

// Delete Camera using DELETE request
// TODO: Add device number option
func DeleteCamera(w http.ResponseWriter, r *http.Request) {
camera.DeleteCamera()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
}
25 changes: 25 additions & 0 deletions internal/adapter/http/front/front.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package front

import (
"net/http"
"text/template"
)

func RegisterRoutes(router *http.ServeMux) {
router.HandleFunc("/", Homepage)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
}

// Return default Homepage, a simple alpineJS application to users
func Homepage(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/html")
template, err := template.ParseFiles("templates/index.html.tpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := template.Execute(w, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
49 changes: 30 additions & 19 deletions internal/adapter/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ package http

import (
"fmt"
"log/slog"
"log"
"net/http"

"github.com/bugbundle/phantom/internal/adapter/http/controls"
"github.com/bugbundle/phantom/internal/adapter/http/front"
"github.com/bugbundle/phantom/internal/adapter/logger"
httpRoutes "github.com/bugbundle/phantom/internal/app/http"
)

func Recovery(next http.Handler) http.Handler {
type service struct {
config config
}

type config struct {
addr string
}

func panicHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
err := recover()
if err != nil {
slog.Error(fmt.Sprint(err))
log.Print(fmt.Errorf("an error occured:\n%s", err))

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
Expand All @@ -26,25 +35,27 @@ func Recovery(next http.Handler) http.Handler {
})
}

func Server(addr string) {
router := http.NewServeMux()
func NewService(addr string) *service {
return &service{
config: config{
addr: addr,
},
}
}

router.HandleFunc("/", httpRoutes.Homepage)
router.HandleFunc("POST /cameras", httpRoutes.CreateCamera)
router.HandleFunc("GET /cameras/status", httpRoutes.StreamStatus)
router.HandleFunc("DELETE /cameras", httpRoutes.DeleteCamera)
router.HandleFunc("GET /cameras", httpRoutes.StreamVideo)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
func (svc *service) Run() error {
router := http.NewServeMux()
controls.RegisterRoutes(router)
front.RegisterRoutes(router)

server_config := &http.Server{
Addr: addr,
serverConfig := &http.Server{
Addr: svc.config.addr,
Handler: logger.LoggingHandler(
Recovery(router),
panicHandler(router),
),
}

slog.Info("Starting server...", "interface", addr)
if err := server_config.ListenAndServe(); err != nil {
slog.Error("An error occured !", "interface", addr, "error", err)
if err := serverConfig.ListenAndServe(); err != nil {
return fmt.Errorf("an error occured while starting the server.\n%w", err)
}
return nil
}
94 changes: 40 additions & 54 deletions internal/port/camera/camera.go
Original file line number Diff line number Diff line change
@@ -1,84 +1,70 @@
package camera

import (
"errors"
"fmt"
"sync"

"github.com/bugbundle/phantom/internal/adapter/camera"
"gocv.io/x/gocv"
)

type WebCamSingleton struct {
Capture *gocv.VideoCapture
type webCamSingleton struct {
capture *gocv.VideoCapture
isOpen bool
mu sync.Mutex
}

var instance *WebCamSingleton
var (
instance *webCamSingleton
once sync.Once
)

// CaptureImage captures an image from the webcam and returns it.
func (wc *WebCamSingleton) CaptureImage() (*gocv.Mat, error) {
if !wc.IsOpen() {
return nil, fmt.Errorf("webcam is not open")
func (w *webCamSingleton) CaptureImage() (*gocv.Mat, error) {
w.mu.Lock()
defer w.mu.Unlock()
if !w.isOpen {
return nil, fmt.Errorf("webcam unavailable")
}

img := gocv.NewMat()
if ok := wc.Capture.Read(&img); !ok {
return nil, fmt.Errorf("failed to read image from webcam")
if ok := w.capture.Read(&img); !ok {
return nil, fmt.Errorf("failed to capture image")
}
return &img, nil
}

func GetCamera() (*WebCamSingleton, error) {
if instance != nil {
return instance, nil
}
return nil, errors.New("camera is missing")
}

func CreateOrGetCamera() *WebCamSingleton {
if instance == nil {
instance = &WebCamSingleton{}
}
func GetInstance() camera.CameraService {
once.Do(func() {
instance = &webCamSingleton{}
})
return instance
}

func DeleteCamera() error {
if instance == nil {
return nil
}

instance.Stop()
instance = nil

return nil
}

func (wc *WebCamSingleton) IsOpen() bool {
return wc.isOpen
}

// Open starts the webcam capture.
func (wc *WebCamSingleton) Open() error {
if wc.isOpen {
return fmt.Errorf("webcam is already open")
// Open opens the webcam if not already open.
func (w *webCamSingleton) Open(deviceId int) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isOpen {
return fmt.Errorf("webcam already open")
}

capture, err := gocv.OpenVideoCapture(0) // Use 0 for default webcam
capture, err := gocv.OpenVideoCapture(deviceId)
if err != nil {
return fmt.Errorf("error opening video capture device: %v", err)
return fmt.Errorf("error opening webcam: %v", err)
}

wc.Capture = capture
wc.isOpen = true
w.capture = capture
w.isOpen = true
return nil
}

// Stop stops the webcam capture.
func (wc *WebCamSingleton) Stop() {
if !wc.isOpen {
fmt.Println("webcam is not open")
return
// Close closes the webcam and releases the resource.
func (w *webCamSingleton) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.isOpen && w.capture != nil {
w.capture.Close()
w.isOpen = false
w.capture = nil
}

wc.Capture.Close()
wc.isOpen = false
// TODO: Add error management later
return nil
}
2 changes: 1 addition & 1 deletion templates/index.html.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
</main>
<script>
async function createCamera() {
await fetch('/cameras', {method: 'POST'});
await fetch('/cameras/0', {method: 'POST'});
location.reload();
};
async function deleteCamera() {
Expand Down