Commit 86ffff7
Changed files (9)
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)