promote
18
testdata/ffmpeg.1/Dockerfile
vendored
Executable file
@@ -0,0 +1,18 @@
|
||||
FROM frolvlad/alpine-glibc:alpine-3.9_glibc-2.29
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
bash
|
||||
|
||||
RUN mkdir -p /var/log
|
||||
WORKDIR /main
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV GOPATH=""
|
||||
ENV MNT="/mnt/"
|
||||
ENTRYPOINT ["bash", "/main/entrypoint.sh"]
|
||||
CMD []
|
||||
|
||||
117
testdata/ffmpeg.1/entrypoint.sh
vendored
Executable file
@@ -0,0 +1,117 @@
|
||||
#! /bin/bash
|
||||
|
||||
SEG="${SEG:-300}"
|
||||
|
||||
function main() {
|
||||
if [ "$#" -eq 0 ]; then
|
||||
log "USAGE: $0 ip ip ip..." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
function clean() {
|
||||
kill -9 $(jobs -p)
|
||||
}
|
||||
if [ "$(hostname)" == "Scratch" ]; then
|
||||
trap clean EXIT ERR
|
||||
fi
|
||||
watchman "$@" &
|
||||
for ip in "$@"; do
|
||||
log recording from $ip...
|
||||
record "$ip" &
|
||||
done
|
||||
|
||||
for i in "$@"; do
|
||||
if ! wait -n 1; then
|
||||
log "Something died: $?"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
done
|
||||
}
|
||||
|
||||
function log() {
|
||||
echo "$(date): $@" >&2
|
||||
}
|
||||
|
||||
function mkdirs() {
|
||||
mkdir -p "${OUT_DIR:-/tmp/ffmpeg_cap}/$(date +%Y)/$(date +%m)/$(date +%d)"
|
||||
}
|
||||
|
||||
function watchman() {
|
||||
log watchman starting on $@
|
||||
local max_last=$SEG
|
||||
max_last=$((max_last*3))
|
||||
function finder() {
|
||||
local suffix="${1:-"*"}"
|
||||
find "${OUT_DIR:-/tmp/ffmpeg_cap}" -type f -name "*_${suffix}.m*"
|
||||
}
|
||||
function mark_last_file() {
|
||||
local now="$(date +%s)"
|
||||
local suffix="$1"
|
||||
local min=$((max_last-1))
|
||||
for f in $(finder "$suffix"); do
|
||||
local ts="$(stat -c '%X' "$f")"
|
||||
if ((now-ts<min)); then
|
||||
min=$((now-ts))
|
||||
fi
|
||||
done
|
||||
echo $min
|
||||
}
|
||||
function check_disk() {
|
||||
if du -s "${OUT_DIR:-/tmp/ffmpeg_cap}" | grep -E '^[6-9][0-9]{5}'; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
while sleep 10; do
|
||||
mkdirs
|
||||
for suffix in "$@"; do
|
||||
local seconds=$(mark_last_file "$suffix")
|
||||
log "$suffix: $seconds s"
|
||||
if ((seconds>max_last)); then
|
||||
log "no new videos in $seconds seconds for $suffix, panicking"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
until check_disk; do
|
||||
local f="$(finder | sort | head -n 1)"
|
||||
log "high disk usage, pruning $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
function record() {
|
||||
log "starting record for $@"
|
||||
_record "$@"
|
||||
tail -n 50 /tmp/ffmpegs.log
|
||||
}
|
||||
|
||||
function _record() {
|
||||
local out="${OUT_DIR:-/tmp/ffmpeg_cap}/%Y/%m/%d/%H-%M-%S_${1}.mp4"
|
||||
mkdirs
|
||||
ffmpeg \
|
||||
-threads 0 \
|
||||
-nostdin \
|
||||
-nostats \
|
||||
-loglevel error \
|
||||
-i rtsp://192.168.0.$1:8554/unicast \
|
||||
-an \
|
||||
-map 0 \
|
||||
-force_key_frames "expr:gte(t,n_forced*9)" \
|
||||
-f segment \
|
||||
-segment_time $SEG \
|
||||
-segment_format mp4 \
|
||||
-strftime 1 \
|
||||
-minrate .05k \
|
||||
-vcodec copy \
|
||||
"$out" \
|
||||
-vf "select=gt(scene\,0.003),setpts=N/(15*TB)" \
|
||||
< /dev/null \
|
||||
>> /tmp/ffmpegs.log \
|
||||
2>&1
|
||||
}
|
||||
|
||||
if [ "$0" == "$BASH_SOURCE" ]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
3
testdata/ffmpeg.d/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
**/*.sw*
|
||||
/cmd/prune/prune
|
||||
/cmd/prune/testdata/**
|
||||
1
testdata/ffmpeg.d/README.md
vendored
@@ -1 +0,0 @@
|
||||
Scratch ~/services/cams.d/*.sh
|
||||
33
testdata/ffmpeg.d/cams.sh
vendored
@@ -1,33 +0,0 @@
|
||||
#! /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
|
||||
266
testdata/ffmpeg.d/cmd/prune/main.go
vendored
@@ -1,266 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"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])
|
||||
}
|
||||
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 {
|
||||
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")
|
||||
|
||||
outd := strings.ReplaceAll(cam, "record", "movement")
|
||||
os.MkdirAll(outd, os.ModePerm)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, cam := range cams {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
73
testdata/ffmpeg.d/cmd/prune/main_test.go
vendored
@@ -1,73 +0,0 @@
|
||||
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) != 1 {
|
||||
t.Fatal(results)
|
||||
} else if path.Base(results[0]) != 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)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
testdata/ffmpeg.d/cmd/prune/testdata/a.jpg
vendored
|
Before Width: | Height: | Size: 9.1 MiB |
BIN
testdata/ffmpeg.d/cmd/prune/testdata/b.jpg
vendored
|
Before Width: | Height: | Size: 8.3 MiB |
3
testdata/ffmpeg.d/go.mod
vendored
@@ -1,3 +0,0 @@
|
||||
module ffmpeg.d
|
||||
|
||||
go 1.22.3
|
||||