Compare commits
52 Commits
9655e792a3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e36e53805 | ||
|
|
9de277a879 | ||
|
|
eb12586a40 | ||
|
|
03565e4fbc | ||
|
|
9bda977a29 | ||
|
|
6a8445fbd0 | ||
|
|
0e4da7e436 | ||
|
|
5b67d5c5f0 | ||
|
|
93aecf47c6 | ||
|
|
69e2789b9b | ||
|
|
3e6d2874d5 | ||
|
|
e377a4f5db | ||
|
|
2668cbed7d | ||
|
|
bea85bc736 | ||
|
|
b5c146bd1d | ||
|
|
c761ea38b8 | ||
|
|
236b1354ac | ||
|
|
3dd752cb34 | ||
|
|
03cd2dedcc | ||
|
|
8119f75279 | ||
|
|
ac4e8aca7c | ||
|
|
05c1795813 | ||
|
|
dcce1dcd19 | ||
|
|
2ac2e0c56c | ||
|
|
6f0ce15715 | ||
|
|
102df09caf | ||
|
|
eb1ea4adb7 | ||
|
|
38ea2b8128 | ||
|
|
5b0b1aaf7f | ||
|
|
42e858cc33 | ||
|
|
9a09eb7c10 | ||
|
|
9a4abb407a | ||
|
|
10122550d3 | ||
|
|
811d3294d4 | ||
|
|
776db80cff | ||
|
|
6ba815f8b2 | ||
|
|
078dd72335 | ||
|
|
122c2d09ec | ||
|
|
84f9896ff4 | ||
|
|
f2d24c6e2a | ||
|
|
8af32661eb | ||
|
|
ddef839399 | ||
|
|
91df362af3 | ||
|
|
beb369623f | ||
|
|
4d14a5073e | ||
|
|
fc6dfaeb0a | ||
|
|
40fb31e6f6 | ||
|
|
43e2d0c14f | ||
|
|
572b46b3d0 | ||
|
|
318592fd4d | ||
|
|
c21a1506b7 | ||
|
|
74672a5e90 |
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/cmd/prune/prune
|
||||||
|
/prune
|
||||||
|
/cmd/ui/ui
|
||||||
|
/ui
|
||||||
|
**/*.sw*
|
||||||
|
**/testdata
|
||||||
33
cams.sh
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00054.jpg
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00055.jpg
vendored
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00056.jpg
vendored
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00057.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00058.jpg
vendored
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00059.jpg
vendored
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00060.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00061.jpg
vendored
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00062.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00063.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00064.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00065.jpg
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00066.jpg
vendored
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.00067.jpg
vendored
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
cmd/prune/testdata/2024-09-17T22-55-38.webm
vendored
Normal file
BIN
cmd/prune/testdata/a.jpg
vendored
Executable file
|
After Width: | Height: | Size: 9.1 MiB |
BIN
cmd/prune/testdata/b.jpg
vendored
Executable file
|
After Width: | Height: | Size: 8.3 MiB |
49
cmd/rmdir/main.go
Normal 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/1.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/2.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/2.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/3.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/3.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/4.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/5.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
1
cmd/ui/testdata/cam/6.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.jpg
|
||||||
1
cmd/ui/testdata/cam/6.mp4
vendored
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
LAdN7KVw-Ig.mp4
|
||||||
BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
cmd/ui/testdata/cam/LAdN7KVw-Ig.mp4
vendored
Normal file
29
pkg/fs/ls.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,28 +1,36 @@
|
|||||||
#! /bin/bash
|
#! /bin/bash
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
if test -f /volume1/homes/squeaky2x3/services/moonfire-nvr.d/moonfire-nvr.sh; then
|
||||||
|
bash /volume1/homes/squeaky2x3/services/moonfire-nvr.d/moonfire-nvr.sh
|
||||||
|
fi
|
||||||
|
if test -d /var/lib/moonfire-nvr; then
|
||||||
|
export MOONFIRE_DIR=/var/lib/moonfire-nvr
|
||||||
|
fi
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local nonrecent_unix="$(($(_date +%s) - 0 * 24 * 60 * 60))" # 1d ago # TODO now
|
local nonrecent_unix="$(($(_date +%s) - 4 * 60 * 60))" # 4h ago
|
||||||
local nonrecent_ts="$(_date -d @$nonrecent_unix -u +%Y-%m-%dT%H:%M:%SZ)"
|
local nonrecent_ts="$(_date -d @$nonrecent_unix -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
|
||||||
for uuid in $(
|
for uuid in $(
|
||||||
api /api/ | jq -r .cameras[].uuid
|
api /api/ | jq -r .cameras[].uuid
|
||||||
); do
|
); do
|
||||||
local streams=(main sub)
|
local stream=main
|
||||||
local recordings=($(
|
local recordings=($(
|
||||||
api /api/cameras/$uuid/$streams/recordings \
|
api /api/cameras/$uuid/$stream/recordings \
|
||||||
| jq -c '.recordings[] | [.startId, .growing, .startTime90k]' \
|
| jq -c '.recordings[] | [.startId, .growing, .startTime90k]' \
|
||||||
| grep -v ,true, \
|
| grep -v ,true, \
|
||||||
| jq -r .[0] \
|
| jq -r .[0] \
|
||||||
| sort -n #-r
|
| sort -n #-r
|
||||||
))
|
))
|
||||||
for recording_idx in $(seq 1 ${#recordings[@]}); do
|
for recording in "${recordings[@]}"; do
|
||||||
local recording=${recordings[recording_idx-1]}
|
if grep "^$uuid.$recording$" ${MOONFIRE_DIR:-/tmp}/movement_detection_via_api_ffmpeg.sh.txt; then
|
||||||
if grep "^$uuid.$recording$" /tmp/movement_detection_via_api_ffmpeg.sh.txt; then
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local ts="$(_date -u -d @"$(
|
local ts="$(_date -u -d @"$(
|
||||||
api "/api/cameras/$uuid/$streams/view.mp4.txt?s=$recording&ts=true" \
|
api "/api/cameras/$uuid/$stream/view.mp4.txt?s=$recording&ts=true" \
|
||||||
| grep -A 1 last_modified: \
|
| grep -A 1 last_modified: \
|
||||||
| head -n 2 \
|
| head -n 2 \
|
||||||
| tail -n 1 \
|
| tail -n 1 \
|
||||||
@@ -34,36 +42,33 @@ main() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo uuid=$uuid stream=$streams recording=$recording ts=$ts
|
echo uuid=$uuid stream=$stream recording=$recording ts=$ts
|
||||||
api "/api/cameras/$uuid/$streams/view.mp4?s=$recording&ts=true" > /tmp/movement_detection_via_api_ffmpeg.sh.mp4
|
api "/api/cameras/$uuid/$stream/view.mp4?s=$recording&ts=true" > /tmp/movement_detection_via_api_ffmpeg.sh.mp4
|
||||||
|
|
||||||
|
local fps=$(_ffprobe -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 2>&1 | grep -o '[0-9]*\.*[0-9]* fps' | head -n 1 | awk '{print $1}')
|
||||||
|
local inspection="$(_ffmpeg -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 -vf 'select=not(mod(n\,'${fps%.*}')),select=gte(scene\,0),metadata=print:file=-' -an -f null - 2> /dev/null | paste - -)"
|
||||||
|
local scores=($(echo "$inspection" | awk '{print $NF}' | sed 's/.*=//'))
|
||||||
|
|
||||||
|
local flattened_scores=()
|
||||||
|
for i in $(seq 1 ${#scores[@]}); do
|
||||||
|
flattened_scores+=($(echo ${scores[@]:i-2:3} | tr ' ' '\n' | sort -n | head -n 2 | tail -n 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
local threshold=1
|
||||||
|
local last_n=(${flattened_scores[@]:0:7})
|
||||||
|
for i in $(seq 1 ${#flattened_scores[@]}); do
|
||||||
|
last_n[$(( (i-1)%7 ))]=${flattened_scores[$((i-1))]}
|
||||||
|
max_of_last_n=$(echo "${last_n[@]}" | tr ' ' '\n' | sort | tail -n 1)
|
||||||
|
if [[ "$max_of_last_n" < "$threshold" ]]; then
|
||||||
|
threshold="$max_of_last_n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
threshold="$(_perl -e "print $threshold + 0.01")"
|
||||||
|
|
||||||
local has_change=false
|
local has_change=false
|
||||||
local cksum="$(cksum /tmp/movement_detection_via_api_ffmpeg.sh.mp4)"
|
if [ "${#flattened_scores[@]}" -lt 10 ]; then
|
||||||
if grep "^$cksum$" /tmp/movement_detection_via_api_ffmpeg.sh.txt; then
|
|
||||||
has_change=true
|
has_change=true
|
||||||
fi
|
else
|
||||||
|
|
||||||
if ! $has_change; then
|
|
||||||
local fps=$(ffprobe -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 2>&1 | grep -o '[0-9]*\.*[0-9]* fps' | head -n 1 | awk '{print $1}')
|
|
||||||
local inspection="$(ffmpeg -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 -vf 'select=not(mod(n\,'${fps%.*}')),select=gte(scene\,0),metadata=print:file=-' -an -f null - 2> /dev/null | paste - -)"
|
|
||||||
local scores=($(echo "$inspection" | awk '{print $NF}' | sed 's/.*=//'))
|
|
||||||
|
|
||||||
local flattened_scores=()
|
|
||||||
for i in $(seq 1 ${#scores[@]}); do
|
|
||||||
flattened_scores+=($(echo ${scores[@]:i-2:3} | tr ' ' '\n' | sort -n | head -n 2 | tail -n 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
local threshold=1
|
|
||||||
local last_n=(${flattened_scores[@]:0:7})
|
|
||||||
for i in $(seq 1 ${#flattened_scores[@]}); do
|
|
||||||
last_n[$(( (i-1)%7 ))]=${flattened_scores[$((i-1))]}
|
|
||||||
max_of_last_n=$(echo "${last_n[@]}" | tr ' ' '\n' | sort | tail -n 1)
|
|
||||||
if [[ "$max_of_last_n" < "$threshold" ]]; then
|
|
||||||
threshold="$max_of_last_n"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
threshold="$(echo "$threshold + 0.01" | bc)"
|
|
||||||
|
|
||||||
local max_flattened_score=$(echo "${flattened_scores[@]:5:${#flattened_scores[@]}-10}" | tr ' ' '\n' | sort -n | tail -n 1)
|
local max_flattened_score=$(echo "${flattened_scores[@]:5:${#flattened_scores[@]}-10}" | tr ' ' '\n' | sort -n | tail -n 1)
|
||||||
if [[ "${max_flattened_score#*.}" > "${threshold#*.}" ]]; then
|
if [[ "${max_flattened_score#*.}" > "${threshold#*.}" ]]; then
|
||||||
has_change=true
|
has_change=true
|
||||||
@@ -71,34 +76,22 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if $has_change; then
|
if $has_change; then
|
||||||
echo "$uuid.$recording" >> /tmp/movement_detection_via_api_ffmpeg.sh.txt
|
echo "$uuid.$recording" >> ${MOONFIRE_DIR:-/tmp}/movement_detection_via_api_ffmpeg.sh.txt
|
||||||
echo "$cksum" >> /tmp/movement_detection_via_api_ffmpeg.sh.txt
|
|
||||||
else
|
else
|
||||||
local next_recording=${recordings[recording_idx]}
|
echo no movement in "$recording"
|
||||||
if [ -z "$next_recording" ] || [ "$recording" == "$next_recording" ]; then
|
api "/api/cameras/$uuid/$stream/view.mp4" \
|
||||||
next_recording="$recording"
|
-X DELETE \
|
||||||
else
|
-H 'Content-Type: application/json' \
|
||||||
next_recording=$((next_recording-1))
|
-d "$(printf '{"runStartId": %s}' "$recording")"
|
||||||
fi
|
|
||||||
echo no movement in "$recording..$next_recording"
|
|
||||||
for j in $(seq $recording $next_recording); do
|
|
||||||
for stream in ${streams[@]}; do
|
|
||||||
echo api "/api/cameras/$uuid/$stream/view.mp4" \
|
|
||||||
-X DELETE \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(printf '{"s": "%s"}' "$j")"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
break
|
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
api() {
|
api() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
shift
|
shift
|
||||||
curl -sS "${CAMS_URL:-https://cams.inhome.blapointe.com}/${path#/}" "$@"
|
curl -sS "${CAMS_URL:-http://192.168.0.86:29898}/${path#/}" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
_date() {
|
_date() {
|
||||||
@@ -109,6 +102,28 @@ _date() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$0" == "$BASH_SOURCE" ]; then
|
_perl() {
|
||||||
main "$@"
|
if which perl &> /dev/null; then
|
||||||
fi
|
perl "$@"
|
||||||
|
else
|
||||||
|
/var/services/homes/squeaky2x3/.nix-profile/bin/perl "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ffprobe() {
|
||||||
|
if which ffprobe &> /dev/null; then
|
||||||
|
ffprobe "$@"
|
||||||
|
else
|
||||||
|
/volume1/homes/squeaky2x3/ffmpeg-local/ffprobe "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ffmpeg() {
|
||||||
|
if which ffmpeg &> /dev/null; then
|
||||||
|
ffmpeg "$@"
|
||||||
|
else
|
||||||
|
/volume1/homes/squeaky2x3/ffmpeg-local/ffmpeg "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
|
|||||||
122
testdata/sentryshot.d/movement_detection_via_api_ffmpeg.sh
vendored
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local nonrecent_unix="$(($(_date +%s) - 10 * 60))" # 10m ago
|
||||||
|
local nonrecent_ts="$(_date -d @$nonrecent_unix -u +%Y-%m-%d_%H-%M-%S)"
|
||||||
|
|
||||||
|
for recording in $(
|
||||||
|
api "/api/recording/query?recording-id=9999-12-28_23-59-59_x&limit=10000&reverse=false&include-data=false" \
|
||||||
|
| jq -r .[].id \
|
||||||
|
| sort
|
||||||
|
); do
|
||||||
|
if [[ "$recording" > "$nonrecent_ts" ]]; then
|
||||||
|
echo "$ts > $nonrecent_ts, so skipping"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep "^$recording$" ${SENTRYSHOT_DIR:-/tmp}/movement_detection_via_api_ffmpeg.sh.txt; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo recording=$recording >&2
|
||||||
|
api "/api/recording/video/$recording" > /tmp/movement_detection_via_api_ffmpeg.sh.mp4
|
||||||
|
|
||||||
|
if video_has_movement; then
|
||||||
|
echo "$recording" >> ${SENTRYSHOT_DIR:-/tmp}/movement_detection_via_api_ffmpeg.sh.txt
|
||||||
|
else
|
||||||
|
echo no movement in "$recording"
|
||||||
|
api "/api/recording/delete/$recording" \
|
||||||
|
-X DELETE
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local too_old_unix="$(($(_date +%s) - 30 * 24 * 60 * 60))" # 30d ago
|
||||||
|
local too_old_ts="$(_date -d @$too_old_unix -u +%Y-%m-%d_%H-%M-%S)"
|
||||||
|
for recording in $(
|
||||||
|
api "/api/recording/query?recording-id=${too_old_ts}_x&limit=10000&reverse=false&include-data=false" \
|
||||||
|
| jq -r .[].id \
|
||||||
|
| sort
|
||||||
|
); do
|
||||||
|
echo "$recording is too old"
|
||||||
|
echo api "/api/recording/delete/$recording" \
|
||||||
|
-X DELETE
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
video_has_movement() {
|
||||||
|
local fps=$(_ffprobe -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 2>&1 | grep -o '[0-9]*\.*[0-9]* fps' | head -n 1 | awk '{print $1}')
|
||||||
|
local inspection="$(_ffmpeg -i /tmp/movement_detection_via_api_ffmpeg.sh.mp4 -vf 'select=not(mod(n\,'${fps%.*}')),select=gte(scene\,0),metadata=print:file=-' -an -f null - 2> /dev/null | paste - -)"
|
||||||
|
local scores=($(echo "$inspection" | awk '{print $NF}' | sed 's/.*=//'))
|
||||||
|
|
||||||
|
local flattened_scores=()
|
||||||
|
for i in $(seq 1 ${#scores[@]}); do
|
||||||
|
flattened_scores+=($(echo ${scores[@]:i-2:3} | tr ' ' '\n' | sort -n | head -n 2 | tail -n 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
local threshold=1
|
||||||
|
local last_n=(${flattened_scores[@]:0:7})
|
||||||
|
for i in $(seq 1 ${#flattened_scores[@]}); do
|
||||||
|
last_n[$(( (i-1)%7 ))]=${flattened_scores[$((i-1))]}
|
||||||
|
max_of_last_n=$(echo "${last_n[@]}" | tr ' ' '\n' | sort | tail -n 1)
|
||||||
|
if [[ "$max_of_last_n" < "$threshold" ]]; then
|
||||||
|
threshold="$max_of_last_n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
threshold="$(_perl -e "print $threshold + 0.01")"
|
||||||
|
|
||||||
|
local has_change=false
|
||||||
|
if [ "${#flattened_scores[@]}" -lt 10 ]; then
|
||||||
|
echo too short >&2
|
||||||
|
has_change=true
|
||||||
|
else
|
||||||
|
local max_flattened_score=$(echo "${flattened_scores[@]:5:${#flattened_scores[@]}-10}" | tr ' ' '\n' | sort -n | tail -n 1)
|
||||||
|
echo "has movement? $max_flattened_score > $threshold" >&2
|
||||||
|
if awk "BEGIN { exit !($max_flattened_score > $threshold)}"; then
|
||||||
|
has_change=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
$has_change
|
||||||
|
}
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local path="$1"
|
||||||
|
shift
|
||||||
|
curl -sS "${CAMS_URL:-http://192.168.0.86:29898}/${path#/}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
_date() {
|
||||||
|
if which gdate &> /dev/null; then
|
||||||
|
gdate "$@"
|
||||||
|
else
|
||||||
|
date "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_perl() {
|
||||||
|
if which perl &> /dev/null; then
|
||||||
|
perl "$@"
|
||||||
|
else
|
||||||
|
/var/services/homes/squeaky2x3/.nix-profile/bin/perl "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ffprobe() {
|
||||||
|
if which ffprobe &> /dev/null; then
|
||||||
|
ffprobe "$@"
|
||||||
|
else
|
||||||
|
/volume1/homes/squeaky2x3/ffmpeg-local/ffprobe "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ffmpeg() {
|
||||||
|
if which ffmpeg &> /dev/null; then
|
||||||
|
ffmpeg "$@"
|
||||||
|
else
|
||||||
|
/volume1/homes/squeaky2x3/ffmpeg-local/ffmpeg "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||