package main import ( // Standard "bufio" "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 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"), "-ImageDescription="+t.Format("2006-01-02 15"), "-overwrite_original", f) exiftool.Run() } } // }}} func timelapse(files []string) { // {{{ sequenceFile := fmt.Sprintf("/tmp/timelapse_sequence") text := `drawtext=text='%{metadata\:ImageDescription}':fontsize=30:x=10:y=10:fontcolor=#71b045:bordercolor=black:borderw=1` filesToSequenceFile(sequenceFile, files) os.Remove(outFilename) ffmpeg := exec.Command("ffmpeg", "-r", framerate, "-safe", "0", "-f", "concat", "-i", sequenceFile, "-filter_complex", text, "-c:v", "libx264", "-crf", "23", "-progress", "-", "-nostats", outFilename) progress, err := ffmpeg.StdoutPipe() if err != nil { panic(err) } err = ffmpeg.Start() if err != nil { panic(err) } // Store position to overwrite progress later. fmt.Print("\x1b[s") rxp := regexp.MustCompile(`^frame=(\d+)$`) ffmpegReader := bufio.NewScanner(progress) var frameStr []string var frame int totalNumFrames := len(files) for ffmpegReader.Scan() { frameStr = rxp.FindStringSubmatch(ffmpegReader.Text()) if len(frameStr) < 2 { continue } frame, _ = strconv.Atoi(frameStr[1]) fmt.Printf("\x1b[u%.0f%% ", float32(frame) / float32(totalNumFrames) * 100) } ffmpeg.Wait() } // }}} func main() { parseCommandLine() if version { fmt.Println(VERSION) return } files := findFiles(startDir) slices.SortStableFunc(files, sortFiles) timelapse(files) }