package main import ( // Standard "flag" "fmt" "os" "os/exec" "path" "regexp" "slices" "strconv" "time" ) const VERSION = "v1" var ( version bool 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.BoolVar(&version, "version", false, "Display version and exit") 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() if version { fmt.Println(VERSION) return } 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) }