Commit 86ffff7

bryfry <bryon@fryer.io>
2025-12-27 14:06:04
2025
1 parent e5ee25e
internal/ffmpeg/ffmpeg.go
@@ -0,0 +1,88 @@
+package ffmpeg
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"syscall"
+	"time"
+)
+
+type Encoder struct {
+	*exec.Cmd
+	stdin io.WriteCloser
+}
+
+func ffmpegArgs(interval time.Duration) []string {
+	framerate := intervalToFramerate(interval)
+	return []string{
+		"-y",                // Overwrite output files without asking.
+		"-loglevel", "info", // logs
+		"-f", "mjpeg", // jpeg source images
+		"-framerate", framerate, // framerate
+		"-i", "pipe:0", // input from pipe
+		"-vf", "setpts=PTS/120,scale=in_range=jpeg:out_range=tv,format=yuv420p", // compression 120x speed
+		"-r", "60",
+		"-c:v", "libx264", "-preset", "slow", "-crf", "25", // H.264 encoding settings
+		"-fps_mode", "cfr", //
+		"-movflags", "+faststart", // webplayback
+		"timelapse.mp4", // output file
+	}
+}
+
+func intervalToFramerate(interval time.Duration) string {
+	if interval < 1 {
+		return "1"
+	}
+	return fmt.Sprintf("%d/%d", time.Second, interval)
+}
+
+func NewEncoder(interval time.Duration) (*Encoder, error) {
+	enc := new(Encoder)
+
+	cmd := exec.Command("ffmpeg", ffmpegArgs(interval)...)
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		err := fmt.Errorf("creating stdin pipe: %w", err)
+		return enc, err
+	}
+
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+	enc.stdin = stdin
+	enc.Cmd = cmd
+
+	logDir := "log"
+	logPath := filepath.Join(logDir, "timelapse.log")
+
+	err = os.MkdirAll(logDir, 0o750)
+	if err != nil {
+		err := fmt.Errorf("creating log dir")
+		return enc, err
+	}
+
+	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
+	if err != nil {
+		err := fmt.Errorf("creating log file")
+		return enc, err
+	}
+	cmd.Stderr = logFile
+	cmd.Stdout = logFile
+
+	err = enc.Start()
+	if err != nil {
+		err := fmt.Errorf("starting encoder")
+		_ = logFile.Close()
+		return enc, err
+	}
+	return enc, nil
+}
+
+func (enc *Encoder) Write(p []byte) (int, error) {
+	return enc.stdin.Write(p)
+}
+
+func (enc *Encoder) Close() error {
+	return enc.stdin.Close()
+}
internal/speco/speco.go
@@ -11,8 +11,8 @@ import (
 )
 
 type Speco struct {
-	url     *url.URL
-	timeout time.Duration
+	url    *url.URL
+	client *http.Client
 }
 
 type JPEG []byte
@@ -22,17 +22,16 @@ func NewCamera(
 	timeout time.Duration,
 ) Speco {
 	return Speco{
-		url:     url,
-		timeout: timeout,
+		url: url,
+		client: &http.Client{
+			Timeout: timeout,
+		},
 	}
 }
 
 func (s Speco) Snapshot(w io.Writer) (int64, error) {
-	client := &http.Client{
-		Timeout: s.timeout,
-	}
 
-	resp, err := client.Get(s.url.String())
+	resp, err := s.client.Get(s.url.String())
 	if err != nil {
 		if urlError, ok := err.(*url.Error); ok && urlError.Timeout() {
 			return 0, err
.gitignore
@@ -1,2 +1,4 @@
 *.jpg
 *.mp4
+/log
+/capture
capture.go
@@ -1,84 +0,0 @@
-package main
-
-import (
-	"christmas-cam/internal/speco"
-	"context"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/url"
-	"os"
-	"os/exec"
-	"time"
-)
-
-func main() {
-
-	snapshotURLString := "http://admin:specospeco1!@192.168.58.58/snapshot.JPG"
-	snapshotURL, err := url.Parse(snapshotURLString)
-	if err != nil {
-		panic(err)
-	}
-	camera := speco.NewCamera(snapshotURL, 3*time.Second)
-	slog.SetLogLoggerLevel(slog.LevelDebug)
-
-	ctx := context.TODO()
-
-	// TODO ffmpeg starter func, returns io.WriteCloser error
-	cmd := exec.CommandContext(ctx, "ffmpeg", ffmpegArgs...)
-	ffmpegPipe, err := cmd.StdinPipe()
-	if err != nil {
-		panic(err)
-	}
-	cmd.Stderr = os.Stderr
-	cmd.Stdout = os.Stderr
-	err = cmd.Start()
-	if err != nil {
-		panic(err)
-	}
-
-	interval := 2 * time.Second
-	next := time.Now()
-	for {
-
-		time.Sleep(time.Until(next))
-		next = next.Add(interval)
-		start := time.Now()
-
-		filename := fmt.Sprintf("%x.jpg", start.Unix())
-		snapshotFile, err := os.Create(filename)
-		if err != nil {
-			slog.Error("failed to create file",
-				slog.String("err", err.Error()),
-				slog.String("file", filename))
-			return
-		}
-		defer snapshotFile.Close()
-
-		mw := io.MultiWriter(snapshotFile, ffmpegPipe)
-		_, err = camera.Snapshot(mw)
-		if err != nil {
-			slog.Error("failed to save image",
-				slog.String("err", err.Error()))
-			continue
-		}
-
-		elapsed := time.Since(start)
-		if elapsed > interval {
-			slog.Warn("snapshot overran interval",
-				slog.String("elapsed", elapsed.String()))
-		}
-	}
-}
-
-var ffmpegArgs = []string{
-	"-y",                // Overwrite output files without asking.
-	"-loglevel", "info", // logs
-	"-f", "mjpeg", "-framerate", "1", "-i", "pipe:0", // input from pipe
-	"-vf", "setpts=PTS/120,scale=in_range=jpeg:out_range=tv,format=yuv420p", // compression 120x speed
-	"-c:v", "libx264", "-preset", "slow", "-crf", "25", // H.264 encoding settings
-	"-g", "600", "-keyint_min", "600", "-bf", "3", // keyframe and b-frame settings
-	"-fps_mode", "passthrough", //
-	"-movflags", "+faststart", // webplayback
-	"timelapse.mp4", // output file
-}
go.mod
@@ -1,3 +1,5 @@
 module christmas-cam
 
-go 1.22.0
+go 1.24.0
+
+require golang.org/x/sync v0.19.0
go.sum
@@ -0,0 +1,2 @@
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
main.go
@@ -0,0 +1,124 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/url"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"time"
+
+	"christmas-cam/internal/ffmpeg"
+	"christmas-cam/internal/speco"
+
+	"golang.org/x/sync/errgroup"
+)
+
+func main() {
+
+	interval := 1600 * time.Millisecond
+
+	snapshotURLString := "http://admin:PASSWORD@192.168.58.58/snapshot.JPG"
+	snapshotURL, err := url.Parse(snapshotURLString)
+	if err != nil {
+		panic(err)
+	}
+	camera := speco.NewCamera(snapshotURL, 4*time.Second)
+	slog.SetLogLoggerLevel(slog.LevelDebug)
+
+	var (
+		ctx, stop = signal.NotifyContext(
+			context.Background(),
+			os.Interrupt,
+		)
+		captureGroup, cctx = errgroup.WithContext(ctx)
+		encodingGroup      = new(errgroup.Group)
+	)
+	defer stop()
+
+	enc, err := ffmpeg.NewEncoder(interval)
+	if err != nil {
+		panic(err)
+	}
+	encodingGroup.Go(func() error {
+		err := enc.Wait()
+		if err != nil {
+			slog.Error("ffmpeg exit",
+				slog.String("error", err.Error()))
+		}
+		return err
+	})
+
+	go func() {
+		<-ctx.Done() // ctrl+c
+		slog.Info("shutdown requested")
+	}()
+
+	captureGroup.Go(func() error {
+		slog.Info("starting image captures")
+		next := time.Now()
+		for {
+
+			select {
+			case <-cctx.Done():
+				slog.Info("closing ffmpeg stdin")
+				_ = enc.Close()
+				return nil // normal shutdown
+			default:
+			}
+
+			time.Sleep(time.Until(next))
+			next = next.Add(interval)
+			start := time.Now()
+
+			filename := fmt.Sprintf("%x.jpg", start.Unix())
+			filepath := filepath.Join("img", filename)
+			slog.Debug(filepath)
+			snapshotFile, err := os.Create(filepath)
+			if err != nil {
+				err = fmt.Errorf("creating snapshot file: %w", err)
+				return err
+			}
+
+			mw := io.MultiWriter(snapshotFile, enc)
+			_, err = camera.Snapshot(mw)
+			if err != nil {
+				slog.Error("writing image to file/pipe",
+					slog.String("error", err.Error()))
+				continue
+			}
+			err = snapshotFile.Close()
+			if err != nil {
+				err = fmt.Errorf("closing snapshot file: %w", err)
+				return err
+			}
+
+			elapsed := time.Since(start)
+			if elapsed > interval {
+				slog.Warn("snapshot overran interval",
+					slog.String("elapsed", elapsed.String()))
+			}
+		}
+	})
+
+	err = captureGroup.Wait()
+	if err != nil {
+		if errors.Is(err, context.Canceled) {
+			slog.Error("ctx done")
+			return
+		}
+		panic(err)
+	}
+	err = encodingGroup.Wait()
+	if err != nil {
+		if errors.Is(err, context.Canceled) {
+			slog.Error("ctx done")
+			return
+		}
+		panic(err)
+	}
+}
README.md
@@ -1,4 +1,7 @@
 # christmas-cam
+
+
+
 # Speco Camera
 
 Find it by mac:
songs.md
@@ -1,5 +1,13 @@
 # Songs used
 
+### 2025 
+- I Saw Three Ships - Manor House String Quartet
+- Here We Come A-Wassailing - Manor House String Quartet
+- The First Nowell - Manor House String Quartet
+
+### 2024
+
+
 ### 2023
 - [Deck the Halls - Camilli String Quartet](https://music.youtube.com/watch?v=6I5OFEq7cpI)
 - [The Most Wonderful Time of Year - Camilli String Quartet](https://music.youtube.com/watch?v=V9x4vXSd7XQ)