timelapse/main.go
2024-10-10 21:22:04 +02:00

202 lines
5.0 KiB
Go

package main
import (
// Standard
"flag"
"fmt"
"os"
"os/exec"
"path"
"regexp"
"slices"
"strconv"
"time"
)
var (
fromHour int
toHour int
startDir string
outFilename string
framerate string
rxpFilename *regexp.Regexp
rxpTimestamp *regexp.Regexp
)
func parseCommandLine() {
workDir, err := os.Getwd()
if err != nil {
panic(err)
}
flag.IntVar(&fromHour, "from", 0, "Hour each day to start picking images from")
flag.IntVar(&toHour, "to", 24, "Hour each day to start picking images up to")
flag.StringVar(&startDir, "dir", workDir, "Directory to find date folders in")
flag.StringVar(&outFilename, "out", "timelapse.mp4", "path and filename for the final timelapse video")
flag.StringVar(&framerate, "framerate", "60", "Framerate")
flag.Parse()
rxpFilename = regexp.MustCompile(`(?i)^(\d+)\.(?:jpg|png)$`)
rxpTimestamp = regexp.MustCompile(`/(\d+)\.(?:\S{3})$`)
}
func findFiles(dir string) (files []string) { // {{{
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
files = append(
files,
findFiles(path.Join(dir, entry.Name()))...,
)
} else {
if validateFilename(entry.Name()) {
files = append(files, path.Join(dir, entry.Name()))
}
}
}
return
} // }}}
func validateFilename(fname string) bool { // {{{
// fname is just a bunch of numbers with a .png or .jpg postfix.
if !rxpFilename.MatchString(fname) {
return false
}
t := filenameToTime(fname, false)
return t.Hour() >= fromHour && t.Hour() <= toHour
} // }}}
func filenameToTime(fname string, fnameWithPath bool) time.Time { // {{{
var tp []string
if fnameWithPath {
tp = rxpTimestamp.FindStringSubmatch(fname)
} else {
tp = rxpFilename.FindStringSubmatch(fname)
}
tsi, _ := strconv.Atoi(tp[1])
ts := int64(tsi)
t := time.Unix(int64(ts), 0)
sweden, _ := time.LoadLocation("Europe/Stockholm")
t = t.In(sweden)
return t
} // }}}
func sortFiles(a, b string) int { // {{{
as := rxpTimestamp.FindStringSubmatch(a)
bs := rxpTimestamp.FindStringSubmatch(b)
an, _ := strconv.Atoi(as[1])
bn, _ := strconv.Atoi(bs[1])
switch {
case an < bn:
return -1
case an > bn:
return 1
}
return 0
} // }}}
func separateDays(files []string) *map[string][]string { // {{{
days := make(map[string][]string, 128)
var t time.Time
var date string
var fnames []string
var found bool
for _, f := range files {
t = filenameToTime(f, true)
date = t.Format("2006-01-02")
fnames, found = days[date]
if !found {
fnames = []string{}
}
fnames = append(fnames, f)
days[date] = fnames
}
return &days
} // }}}
func filesToSequenceFile(fname string, files []string) { // {{{
out, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer out.Close()
for _, f := range files {
_, err = out.WriteString(
fmt.Sprintf("file '%s'\n", f),
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
} // }}}
func updateEXIFTimestamp(files []string) { // {{{
for _, f := range files {
t := filenameToTime(f, true)
fmt.Printf("exif for %s\n", f)
exiftool := exec.Command("exiftool", "-CreateDate="+t.Format("2006-01-02 15:04"), "-overwrite_original", f)
exiftool.Run()
}
} // }}}
func timelapse(date string, files []string) { // {{{
sequenceFile := fmt.Sprintf("/tmp/timelapse/sequence_%s", date)
videoFile := fmt.Sprintf("/tmp/timelapse/video_%s.mp4", date)
text := `drawtext=text='%{metadata\:DateTimeDigitized}':fontsize=30:x=10:y=10:fontcolor=#71b045:bordercolor=black:borderw=1`
filesToSequenceFile(sequenceFile, files)
os.Remove(videoFile)
ffmpeg := exec.Command("ffmpeg", "-r", framerate, "-safe", "0", "-f", "concat", "-i", sequenceFile, "-filter_complex", text, "-c:v", "libx264", "-crf", "23", videoFile)
out, err := ffmpeg.CombinedOutput()
if err != nil {
fmt.Printf("%s\n", out)
panic(err)
}
} // }}}
func concatVideos(unsortedDates []string) { // {{{
fname := "/tmp/timelapse/sequence_all"
var videos []string
for _, date := range unsortedDates {
videos = append(videos, "/tmp/timelapse/video_"+date+".mp4")
}
slices.Sort(videos)
filesToSequenceFile(fname, videos)
os.Remove(outFilename)
ffmpeg := exec.Command("ffmpeg", "-safe", "0", "-f", "concat", "-i", fname, "-c", "copy", outFilename)
out, err := ffmpeg.CombinedOutput()
if err != nil {
fmt.Printf("%s\n", out)
panic(err)
}
} // }}}
func main() {
parseCommandLine()
files := findFiles(startDir)
slices.SortStableFunc(files, sortFiles)
sortedDays := separateDays(files)
os.Mkdir("/tmp/timelapse", 0755)
var dates []string
for date, files := range *sortedDays {
fmt.Printf("Create timelapse for %s\n", date)
dates = append(dates, date)
// This is now done when downloading the image from the camera,
// since it takes too damn long time when running this program.
// It is preserved for the times when the source images are missing
// the EXIF data.
// updateEXIFTimestamp(files)
timelapse(date, files)
}
fmt.Println("Assemble day videos to complete timelapse")
concatVideos(dates)
}