commit 1f3d87d37fb0639bfbb9dd831047eaf2b41b9205 Author: Magnus Ă…hall Date: Thu Oct 10 09:05:32 2024 +0200 Initial commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2786b1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module timelapse + +go 1.23.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..778fe20 --- /dev/null +++ b/main.go @@ -0,0 +1,188 @@ +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 timelapse(date string, files []string) { // {{{ + sequenceFile := fmt.Sprintf("/tmp/timelapse/sequence_%s", date) + videoFile := fmt.Sprintf("/tmp/timelapse/video_%s.mp4", date) + text := fmt.Sprintf("drawtext=text='%s':fontsize=30:x=10:y=10:fontcolor=#71b045:bordercolor=black:borderw=1", date) + + filesToSequenceFile(sequenceFile, files) + os.Remove(videoFile) + ffmpeg := exec.Command("ffmpeg", "-safe", "0", "-f", "concat", "-i", sequenceFile, "-filter_complex", text, "-framerate", framerate, "-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) + timelapse(date, files) + } + + fmt.Println("Assemble day videos to complete timelapse") + concatVideos(dates) +}