package main import ( "context" "fmt" "io" "math/big" "os" "os/exec" "os/signal" "path" "slices" "sort" "strconv" "strings" "syscall" "time" ) func main() { ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT) defer can() if err := Run(ctx, os.Args[1:]); err != nil { panic(err) } } func Run(ctx context.Context, args []string) error { cams, err := lsd(args[0]) if err != nil { return fmt.Errorf("failed to lsd %s: %w", args[0], err) } movementInterval, err := time.ParseDuration(args[1]) if err != nil { return err } for _, cam := range cams { files, err := lsf(cam) if err != nil { return fmt.Errorf("failed to lsf %s: %w", cam, err) } else if len(files) < 1 { continue } series := []string{} for _, f := range files { series = append(series, strings.Split(path.Base(f), ".")[0]) } series = slices.Compact(series) if len(series) < 1 { continue } series = series[:len(series)-1] for _, series := range series { if err := func() error { seriesFiles := []string{} for _, file := range files { if strings.HasPrefix(path.Base(file), series) { seriesFiles = append(seriesFiles, file) } } if seriesHasMovement, err := seriesHasMovement(ctx, seriesFiles, movementInterval); err != nil { return err } else if seriesHasMovement { outd := strings.ReplaceAll(cam, "record", "movement") os.MkdirAll(outd, os.ModePerm) cmd := exec.CommandContext(ctx, "convert", append(seriesFiles, "-delay", "20", // 20 frames at 60fps path.Join(outd, series+".gif"), )..., //"ffmpeg", //"-y", //"-framerate", "1", //"-pattern_type", "glob", //"-i", path.Join(cam, series)+".*.jpg", //"-r", "3", //path.Join(outd, series+".webm"), ) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to webm series %s: (%w) %s", series, err, out) } } for _, seriesFile := range seriesFiles { if err := os.Remove(seriesFile); err != nil { return fmt.Errorf("failed to rm series %s[%s]: %w", series, seriesFile, err) } } return nil }(); err != nil { return err } } } return nil } func seriesHasMovement(ctx context.Context, files []string, movementInterval time.Duration) (bool, error) { if len(files) < 1 { return false, nil } f := files[0] fStat, err := os.Stat(f) if err != nil { return false, err } fTime := fStat.ModTime() for i := 1; i < len(files); i++ { g := files[i] gStat, err := os.Stat(g) if err != nil { return false, err } gTime := gStat.ModTime() if gTime.Sub(fTime) < movementInterval { continue } if hasMovement, err := hasMovement(ctx, f, g); err != nil { return false, fmt.Errorf("failed to check for movement between %s and %s: %w", f, g, err) } else if hasMovement { return true, nil } f = g fTime = gTime } return false, nil } func hasMovement(ctx context.Context, a, b string) (bool, error) { sizeOfCmd := exec.CommandContext(ctx, "identify", a) sizeOfOutput, err := sizeOfCmd.CombinedOutput() if err != nil { return false, fmt.Errorf("failed to identify %s: (%w) %s", a, err, sizeOfOutput) } hw := strings.Fields(string(sizeOfOutput))[2] h, err := strconv.ParseInt(strings.Split(hw, "x")[0], 10, 16) if err != nil { return false, fmt.Errorf("failed parsing %s for HxW: %w", hw, err) } w, err := strconv.ParseInt(strings.Split(hw, "x")[1], 10, 16) if err != nil { return false, fmt.Errorf("failed parsing %s for HxW: %w", hw, err) } total := h * w compareCmd := exec.CommandContext(ctx, "compare", "-metric", "AE", "-fuzz", "15%", a, b, "/dev/null") compareOutput, _ := compareCmd.CombinedOutput() f, _, err := big.ParseFloat(string(compareOutput), 10, 0, big.ToNearestEven) if err != nil { return false, fmt.Errorf("failed to parse %s for a number of changed pixels: %w", compareOutput, err) } i := new(big.Int) f.Int(i) delta := i.Int64() percentPixelsChanged := int(100.0 * float64(delta) / float64(total)) return percentPixelsChanged > 10, nil } func mv(wPath, rPath string) error { r, err := os.Open(rPath) if err != nil { return err } defer r.Close() os.MkdirAll(path.Dir(wPath), os.ModePerm) w, err := os.Create(wPath) if err != nil { return err } defer w.Close() if _, err := io.Copy(w, r); err != nil { w.Close() os.Remove(wPath) return err } return os.Remove(rPath) } func lsd(d string) ([]string, error) { return ls(d, true) } func lsf(d string) ([]string, error) { return ls(d, false) } func ls(d string, dirs bool) ([]string, error) { entries, err := os.ReadDir(d) results := make([]string, 0, len(entries)) for i := range entries { if dirs == entries[i].IsDir() { results = append(results, path.Join(d, entries[i].Name())) } } sort.Strings(results) return results, err }