189 lines
4.5 KiB
Go
189 lines
4.5 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 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)
|
||
|
}
|