Compare commits

..

37 Commits

Author SHA1 Message Date
Bel LaPointe
3e36e53805 no catch sigint 2024-11-18 06:51:17 -07:00
Bel LaPointe
9de277a879 i messed up 2024-11-17 23:13:27 -07:00
bel
eb12586a40 DONT FAIL FOREVER ON 1 BLACKOUT FILE loser 2024-11-16 07:30:25 -07:00
Bel LaPointe
03565e4fbc cache media 2024-09-19 13:54:02 -04:00
Bel LaPointe
9bda977a29 prune also stashes the first jpg with movement as movement/cam/series.jpg 2024-09-19 12:36:26 -04:00
Bel LaPointe
6a8445fbd0 draw even if no thumbnail avail 2024-09-19 12:31:13 -04:00
Bel LaPointe
0e4da7e436 sort by href 2024-09-19 12:30:01 -04:00
Bel LaPointe
5b67d5c5f0 sort desc by date 2024-09-19 12:24:00 -04:00
Bel LaPointe
93aecf47c6 more files 2024-09-19 12:17:25 -04:00
Bel LaPointe
69e2789b9b 38080 2024-09-19 12:16:31 -04:00
Bel LaPointe
3e6d2874d5 ui mvp ish 2024-09-19 12:16:11 -04:00
Bel LaPointe
e377a4f5db ignore testdata 2024-09-19 12:07:40 -04:00
Bel LaPointe
2668cbed7d refactor /cmd/prune/main.go#ls to /pkg/fs/ls.go 2024-09-19 11:25:32 -04:00
Bel LaPointe
bea85bc736 no binary 2024-09-19 11:25:09 -04:00
Bel LaPointe
b5c146bd1d gitignore 2024-09-19 11:25:03 -04:00
Bel LaPointe
c761ea38b8 promote 2024-09-19 10:56:09 -04:00
Bel LaPointe
236b1354ac prune after 100gibdisk 2024-09-19 10:03:20 -04:00
Bel LaPointe
3dd752cb34 compute fps 2024-09-19 07:44:48 -04:00
Bel LaPointe
03cd2dedcc pull 2024-09-19 07:40:22 -04:00
Bel LaPointe
8119f75279 getting a pallete for a nice gif takes 14s, but mp4 only takes 8s total and has smaller file anyway 2024-09-18 12:08:58 -04:00
Bel LaPointe
ac4e8aca7c touch no series started in the last 10min 2024-09-18 12:01:00 -04:00
Bel LaPointe
05c1795813 oops maybe 2024-09-18 11:47:01 -04:00
Bel LaPointe
dcce1dcd19 logs 2024-09-18 11:40:39 -04:00
Bel LaPointe
2ac2e0c56c comparison: gif=2s+14MB webm=8s+3MB mp4=4s+8MB 2024-09-18 11:36:37 -04:00
Bel LaPointe
6f0ce15715 gif 2024-09-18 11:33:35 -04:00
Bel LaPointe
102df09caf imagemagick gif so slow 2024-09-18 11:29:09 -04:00
Bel LaPointe
eb1ea4adb7 passes tests by converting a series to a webm 2024-09-18 11:26:41 -04:00
Bel LaPointe
38ea2b8128 passes tests by converting a series to a webm 2024-09-18 11:26:31 -04:00
Bel LaPointe
5b0b1aaf7f testfiles for making a series into a webm 2024-09-18 11:16:28 -04:00
Bel LaPointe
42e858cc33 accept args as PARENT_OF_DIRS DURATION_INTERVAL_FOR_MOVEMENT 2024-09-18 11:13:27 -04:00
Bel LaPointe
9a09eb7c10 check for movement every 1s apart 2024-09-18 11:12:11 -04:00
Bel LaPointe
9a4abb407a bettertest 2024-09-18 11:04:34 -04:00
Bel LaPointe
10122550d3 test passes 2024-09-18 11:02:03 -04:00
Bel LaPointe
811d3294d4 test 2024-09-18 10:44:54 -04:00
Bel LaPointe
776db80cff ignore future testdata 2024-09-18 10:36:03 -04:00
Bel LaPointe
6ba815f8b2 prune testdata 2024-09-18 10:35:51 -04:00
bel
078dd72335 doit 2024-09-17 23:01:02 -06:00
46 changed files with 615 additions and 157 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/cmd/prune/prune
/prune
/cmd/ui/ui
/ui
**/*.sw*
**/testdata

33
cams.sh Normal file
View File

@@ -0,0 +1,33 @@
#! /bin/bash
set -euo pipefail
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
mkdir -p ./record.d/
for i in 83 98; do
(
while sleep 1; do
mkdir -p ./record.d/$i/
/var/services/homes/squeaky2x3/bin/ffmpeg7 \
-y \
-rtsp_transport udp \
-i rtsp://192.168.0.$i:8554/unicast \
-t 180 \
./record.d/$i/$(date +%Y-%m-%dT%H-%M-%S).%05d.jpg \
< /dev/null &> ./record.d/$i/ffmpeg.log
done
) &
done
cleanup() {
local jobs="$(jobs -p)"
if [ -n "$jobs" ]; then
echo -e "killing $jobs"
kill -9 $jobs
wait
fi
}
trap cleanup EXIT
echo -e "waiting..."
wait

267
cmd/prune/main.go Normal file
View File

@@ -0,0 +1,267 @@
package main
import (
"context"
"fmt"
"io"
"log"
"math/big"
"os"
"os/exec"
"os/signal"
"path"
"slices"
"sort"
"strconv"
"strings"
"syscall"
"time"
"ffmpeg.d/pkg/fs"
)
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])
}
sort.Strings(series)
series = slices.Compact(series)
series = slices.DeleteFunc(series, func(s string) bool {
return path.Base(s) > time.Now().Add(-10*time.Minute).Format("2006-01-02T15-04-05.")
})
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 > -1 {
outd := strings.ReplaceAll(cam, "record", "movement")
os.MkdirAll(outd, os.ModePerm)
fileWithMovement := seriesFiles[seriesHasMovement]
if b, err := os.ReadFile(fileWithMovement); err != nil {
return err
} else if err := os.WriteFile(path.Join(outd, series+path.Ext(fileWithMovement)), b, os.ModePerm); err != nil {
return err
}
firstFileInfo, err := os.Stat(seriesFiles[0])
if err != nil {
return err
}
lastFileInfo, err := os.Stat(seriesFiles[len(seriesFiles)-1])
if err != nil {
return err
}
durationSeconds := lastFileInfo.ModTime().Sub(firstFileInfo.ModTime()).Seconds()
frames := len(seriesFiles)
fps := int(float64(frames) / durationSeconds)
log.Println("found movement in", series, "over", durationSeconds, "seconds at", fps, "fps")
cmd := exec.CommandContext(ctx,
"ffmpeg",
"-y",
"-framerate", strconv.Itoa(fps),
"-pattern_type", "glob",
"-i", path.Join(cam, series)+".*.jpg",
"-r", strconv.Itoa(fps),
path.Join(outd, series+".mp4"),
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to persist series %s: (%w) %s", series, err, out)
}
} else {
log.Println("no movement in", series)
}
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 {
log.Println("failed series", series, ":", err)
}
}
}
for _, cam := range cams {
if err := func() error {
camMovementD := strings.ReplaceAll(cam, "record", "movement")
movementFiles, err := lsf(camMovementD)
if err != nil {
return err
}
sort.Strings(movementFiles)
var sizeOfCamDMiB int64
for _, movementFile := range movementFiles {
stat, err := os.Stat(movementFile)
if err != nil {
return err
}
fileSizeMiB := stat.Size() / 1024 / 1024
sizeOfCamDMiB += fileSizeMiB
}
GiB100 := int64(100 * 1024)
for sizeOfCamDMiB > GiB100 && len(movementFiles) > 0 {
oldestFile := movementFiles[0]
oldestFileStat, err := os.Stat(oldestFile)
if err != nil {
return err
}
oldestFileSizeMiB := oldestFileStat.Size() / 1024 / 1024
log.Println("deleting old file", oldestFile)
if err := os.Remove(oldestFile); err != nil {
return err
}
sizeOfCamDMiB -= oldestFileSizeMiB
movementFiles = movementFiles[1:]
}
return nil
}(); err != nil {
log.Println("failed to cap size of cam", cam, " dir:", err)
}
}
return nil
}
func seriesHasMovement(ctx context.Context, files []string, movementInterval time.Duration) (int, error) {
if len(files) < 1 {
return -1, nil
}
f := files[0]
fStat, err := os.Stat(f)
if err != nil {
return -1, err
}
fTime := fStat.ModTime()
for i := 1; i < len(files); i++ {
g := files[i]
gStat, err := os.Stat(g)
if err != nil {
return -1, err
}
gTime := gStat.ModTime()
if gTime.Sub(fTime) < movementInterval {
continue
}
if hasMovement, err := hasMovement(ctx, f, g); err != nil {
return -1, fmt.Errorf("failed to check for movement between %s and %s: %w", f, g, err)
} else if hasMovement {
return i, nil
}
f = g
fTime = gTime
}
return -1, 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 fs.LsD(d)
}
func lsf(d string) ([]string, error) {
return fs.LsF(d)
}

75
cmd/prune/main_test.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"context"
"os"
"path"
"testing"
"time"
)
func TestRun(t *testing.T) {
d := path.Join(t.TempDir(), "record")
if err := os.MkdirAll(d, os.ModePerm); err != nil {
t.Fatal(err)
}
a, err := os.ReadFile(path.Join("testdata", "a.jpg"))
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(path.Join("testdata", "b.jpg"))
if err != nil {
t.Fatal(err)
}
seriesA := "2024-09-18T10-50-00"
seriesB := time.Now().Format("2006-01-02T15-04-05")
now := time.Now()
if err := os.WriteFile(path.Join(d, seriesA+".0.jpg"), a, os.ModePerm); err != nil {
t.Fatal(err)
} else if err := os.Chtimes(path.Join(d, seriesA+".0.jpg"), now, now.Add(-4000*time.Millisecond)); err != nil {
t.Fatal(err)
} else if err := os.WriteFile(path.Join(d, seriesA+".1.jpg"), a, os.ModePerm); err != nil {
t.Fatal(err)
} else if err := os.Chtimes(path.Join(d, seriesA+".1.jpg"), now, now.Add(-3500*time.Millisecond)); err != nil {
t.Fatal(err)
} else if err := os.WriteFile(path.Join(d, seriesA+".2.jpg"), b, os.ModePerm); err != nil {
t.Fatal(err)
} else if err := os.Chtimes(path.Join(d, seriesA+".2.jpg"), now, now.Add(-3000*time.Millisecond)); err != nil {
t.Fatal(err)
} else if err := os.WriteFile(path.Join(d, seriesA+".3.jpg"), b, os.ModePerm); err != nil {
t.Fatal(err)
} else if err := os.Chtimes(path.Join(d, seriesA+".3.jpg"), now, now.Add(-2500*time.Millisecond)); err != nil {
t.Fatal(err)
} else if err := os.WriteFile(path.Join(d, seriesB+".0.jpg"), b, os.ModePerm); err != nil {
t.Fatal(err)
} else if err := os.Chtimes(path.Join(d, seriesB+".0.jpg"), now, now.Add(-2000*time.Millisecond)); err != nil {
t.Fatal(err)
}
start := time.Now()
if err := Run(context.Background(), []string{path.Dir(d), "1s"}); err != nil {
t.Fatal(err)
}
t.Log(time.Since(start))
if results, err := lsf(path.Join(path.Dir(d), "movement")); err != nil {
t.Fatal("failed to lsf for results:", err)
} else if len(results) != 2 {
t.Fatal(results)
} else if path.Base(results[0]) != seriesA+".jpg" {
t.Fatal(results)
} else if path.Base(results[1]) != seriesA+".mp4" {
t.Fatal(results)
}
if records, err := lsf(d); err != nil {
t.Fatal(err)
} else if len(records) != 1 {
t.Fatal(records)
} else if path.Base(records[0]) != seriesB+".0.jpg" {
t.Fatal(err)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

BIN
cmd/prune/testdata/a.jpg vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 MiB

BIN
cmd/prune/testdata/b.jpg vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

49
cmd/rmdir/main.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"context"
"fmt"
"log"
"os"
"ffmpeg.d/pkg/fs"
)
func main() {
if err := Run(context.Background(), 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)
}
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
}
for _, file := range files {
log.Println("rm", file)
if os.Getenv("DRY_RUN") == "false" {
os.Remove(file)
}
}
}
return nil
}
func lsd(d string) ([]string, error) {
return fs.LsD(d)
}
func lsf(d string) ([]string, error) {
return fs.LsF(d)
}

21
cmd/ui/index.html.tmpl Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
</header>
<body>
<div>
{{ range .Series }}
<div style="display: inline-block; width: 12em; height: 9em; margin-bottom: .7em;">
<a href="/media/{{ .HREF }}">
<img src="/media/{{ .Thumbnail }}" alt="{{ .HREF }}"/>
<span>{{ .Thumbnail }}</span>
<br>
</a>
</div>
{{ end }}
</div>
</body>
<footer>
</footer>
</html>

125
cmd/ui/main.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"context"
"embed"
_ "embed"
"flag"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"os/signal"
"path"
"slices"
"strings"
"syscall"
"ffmpeg.d/pkg/fs"
)
//go:embed *.tmpl
var TMPL embed.FS
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 {
flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
d := flags.String("d", "./testdata/", "directory containing directories of (x.jpg,x.mp4)")
p := flags.Int("p", 38080, "port to listen on")
if err := flags.Parse(args); err != nil {
return err
}
tmpl, err := template.ParseFS(TMPL, "*.tmpl")
if err != nil {
return err
}
s := &http.Server{
Addr: fmt.Sprintf(":%d", *p),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/media/") {
http.StripPrefix("/media/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "private, max-age=31536000")
w.Header().Add("Cache-Control", "immutable")
http.FileServer(http.Dir(*d)).ServeHTTP(w, r)
})).ServeHTTP(w, r)
return
}
type Series struct {
HREF string
Thumbnail string
}
ds, err := fs.LsD(*d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
seriesByKey := map[string]Series{}
for _, d := range ds {
files, err := fs.LsF(d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, f := range files {
key := strings.Split(path.Base(f), ".")[0]
v := seriesByKey[key]
switch path.Ext(f) {
case ".jpg":
v.Thumbnail = path.Join(path.Base(d), path.Base(f))
case ".mp4":
v.HREF = path.Join(path.Base(d), path.Base(f))
}
seriesByKey[key] = v
}
}
series := []Series{}
for _, v := range seriesByKey {
series = append(series, v)
}
slices.SortFunc(series, func(a, b Series) int {
return -1 * strings.Compare(path.Base(a.HREF), path.Base(b.HREF))
})
if err := tmpl.Execute(w, map[string]any{
"Series": series,
}); err != nil {
log.Println(err)
}
}),
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
defer s.Shutdown(ctx)
errs := make(chan error)
go func() {
defer close(errs)
errs <- s.ListenAndServe()
}()
select {
case err := <-errs:
return err
case <-ctx.Done():
s.Shutdown(ctx)
for range errs {
}
}
return nil
}

1
cmd/ui/testdata/cam/1.jpg vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.jpg

1
cmd/ui/testdata/cam/1.mp4 vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.mp4

1
cmd/ui/testdata/cam/2.jpg vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.jpg

1
cmd/ui/testdata/cam/2.mp4 vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.mp4

1
cmd/ui/testdata/cam/3.jpg vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.jpg

1
cmd/ui/testdata/cam/3.mp4 vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.mp4

1
cmd/ui/testdata/cam/4.jpg vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.jpg

1
cmd/ui/testdata/cam/5.mp4 vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.mp4

1
cmd/ui/testdata/cam/6.jpg vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.jpg

1
cmd/ui/testdata/cam/6.mp4 vendored Symbolic link
View File

@@ -0,0 +1 @@
LAdN7KVw-Ig.mp4

BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.mp4 vendored Normal file

Binary file not shown.

29
pkg/fs/ls.go Normal file
View File

@@ -0,0 +1,29 @@
package fs
import (
"os"
"path"
"sort"
)
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
}

View File

@@ -1,2 +0,0 @@
**/*.sw*
/cmd/prune/prune

View File

@@ -1,155 +0,0 @@
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 err
}
for _, cam := range cams {
files, err := lsf(cam)
if err != nil {
return err
} else if len(files) < 1 {
continue
}
lastChunk := strings.Split(path.Base(files[len(files)-1]), ".")[0]
files = slices.DeleteFunc(files, func(f string) bool {
return strings.Contains(f, lastChunk)
})
if len(files) == 0 {
continue
}
lastMovementAt := time.Unix(0, 0)
prevF := files[0]
for _, f := range files[1:] {
prev, err := os.Stat(prevF)
if err != nil {
return err
}
if ok, err := hasMovement(ctx, prevF, f); err != nil {
return err
} else if ok {
lastMovementAt = prev.ModTime()
}
target := "trash"
if prev.ModTime().Before(lastMovementAt.Add(3 * time.Minute)) {
target = "movement"
}
if err := func() error {
gName := strings.ReplaceAll(prevF, "record", target)
if gName == prevF {
return fmt.Errorf("would overwrite original %s", prevF)
}
os.MkdirAll(path.Dir(gName), os.ModePerm)
f, err := os.Open(prevF)
if err != nil {
return err
}
defer f.Close()
g, err := os.Create(gName)
if err != nil {
return err
}
defer g.Close()
if _, err := io.Copy(g, f); err != nil {
return err
}
return os.Remove(prevF)
}(); err != nil {
return err
}
prevF = f
}
}
return 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, err
}
w, err := strconv.ParseInt(strings.Split(hw, "x")[1], 10, 16)
if err != nil {
return false, 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, err
}
i := new(big.Int)
f.Int(i)
delta := i.Int64()
percentPixelsChanged := int(100.0 * float64(delta) / float64(total))
return percentPixelsChanged > 10, nil
}
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
}