An art project of mine; showing 30 second video clips of calm places & moments I've enjoyed being in.
stream.place/byjp.me
video
streaming
art
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "os"
8 "os/exec"
9 "path"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "time"
14)
15
16const (
17 width = 1280
18 height = 720
19 length = 30 * time.Second
20 outdir = "public"
21)
22
23type ffprobe struct {
24 Streams []struct {
25 Duration string
26 CodecType string `json:"codec_type"`
27 Tags struct {
28 CreationTime time.Time `json:"creation_time"`
29 } `json:"tags"`
30 }
31}
32
33func check(err error) {
34 if err != nil {
35 log.Fatal(err)
36 }
37}
38
39func main() {
40 ins, err := filepath.Glob("input/*.*")
41 check(err)
42
43 for _, in := range ins {
44 if path.Base(in) == ".DS_Store" {
45 continue
46 }
47
48 videoLength, created, err := getVideoMetadata(in)
49 check(err)
50 start := (videoLength - length) / 2
51
52 outPath, err := multiEncode(in, start, created)
53 if err != nil {
54 log.Println(err)
55 continue
56 }
57
58 fmt.Println("Encoded", outPath)
59 }
60}
61
62func getVideoMetadata(path string) (time.Duration, time.Time, error) {
63 cmd := exec.Command("/opt/homebrew/bin/ffprobe",
64 "-print_format", "json",
65 "-show_format", "-show_streams", path)
66
67 out, err := cmd.Output()
68 if err != nil {
69 if exitErr, ok := err.(*exec.ExitError); ok {
70 return 0, time.Time{}, fmt.Errorf("exit error %s: %w", strings.Join(cmd.Args, " "), exitErr)
71 }
72 return 0, time.Time{}, err
73 }
74
75 var data ffprobe
76 if err := json.Unmarshal(out, &data); err != nil {
77 return 0, time.Time{}, err
78 }
79
80 for _, stream := range data.Streams {
81 if stream.CodecType == "video" {
82 durationSeconds, err := strconv.ParseFloat(stream.Duration, 32)
83 if err != nil {
84 return 0, time.Time{}, err
85 }
86 duration := time.Duration(int(durationSeconds*1000)) * time.Millisecond
87 return duration, stream.Tags.CreationTime, nil
88 }
89 }
90 return 0, time.Time{}, fmt.Errorf("no video stream")
91}
92
93func multiEncode(path string, start time.Duration, created time.Time) (string, error) {
94 base := filepath.Base(path)
95 ext := filepath.Ext(path)
96 name := strings.TrimSuffix(base, ext)
97 lenMinus3 := len(name) - 3
98 noAudio := name[lenMinus3:] == ".na"
99 audioFilter := "anullsrc=r=44100:cl=stereo:d=32[silence];[0:a]adelay=1000|1000,afade=in:st=1:d=1,afade=out:st=30:d=1,apad=whole_dur=32[delayed];[silence][delayed]amix=inputs=2:duration=first:normalize=0,asetpts=PTS-STARTPTS[outa]"
100 if noAudio {
101 name = name[0:lenMinus3]
102 audioFilter = "anullsrc=r=44100:cl=stereo:d=32,asetpts=PTS-STARTPTS[outa]"
103 }
104
105 outPath := filepath.Join(outdir, name+".ts")
106
107 args := []string{
108 "-ss", fmt.Sprintf("%0.3f", start.Seconds()), "-t", "30", "-i", path,
109 "-loop", "1", "-to", "32", "-i", "interstitial.png",
110 "-filter_complex", fmt.Sprintf("%s;[0:v]scale=w=%d:h=%d:force_original_aspect_ratio=increase,format=rgba,setpts=PTS-STARTPTS+1/TB,fade=in:st=1:d=1:alpha=1,fade=out:st=30:d=1:alpha=1[v];[1:v]scale=w=%d:h=%d,format=rgba[bg];[bg][v]overlay=eof_action=pass[outv_rgba];[outv_rgba]format=yuv420p[outv]", audioFilter, width, height, width, height),
111 "-map", "[outv]", "-map", "[outa]",
112
113 // H264 video
114 "-c:v", "libx264", "-profile:v", "high",
115 "-b:v", "6000k", "-maxrate", "6000k", "-bufsize", "6000k",
116 "-g", "50", "-force_key_frames", "expr:gte(t,n_forced*2)", // Keyframe every 2 seconds at 25fps
117 "-preset", "veryslow",
118 "-x264-params", "bframes=0:scenecut=0",
119
120 // AAC audio
121 "-c:a", "aac", "-ar", "44100", "-ac", "2", "-b:a", "128k",
122
123 "-muxdelay", "0",
124 "-muxpreload", "0",
125 "-output_ts_offset", "0",
126 "-fflags", "+genpts",
127
128 "-f", "mpegts",
129 "-y", "-to", "32", outPath,
130 }
131
132 cmd := exec.Command("/opt/homebrew/bin/ffmpeg", args...)
133 cmd.Stderr = os.Stderr
134
135 _, cmdErr := cmd.Output()
136 if cmdErr != nil {
137 if exitErr, ok := cmdErr.(*exec.ExitError); ok {
138 return "", fmt.Errorf("exit error %s: %w", strings.Join(cmd.Args, " "), exitErr)
139 }
140 return "", fmt.Errorf("ffmpeg error %s: %w", strings.Join(cmd.Args, " "), cmdErr)
141 }
142
143 // Validate output before moving source to done
144 if err := validateOutput(outPath); err != nil {
145 _ = os.Remove(outPath)
146 return "", fmt.Errorf("validation failed for %s: %w", path, err)
147 }
148
149 movePath := filepath.Join(filepath.Dir(path), "done", filepath.Base(path))
150 _ = os.Rename(path, movePath)
151
152 return outPath, nil
153}
154
155const (
156 expectedVideoFrames = 800
157 expectedAACFrames = 1380 // 44100 Hz × 32 seconds ÷ 1024 samples/frame ≈ 1378, rounded up
158)
159
160func validateOutput(path string) error {
161 // Count video frames
162 videoCmd := exec.Command("/opt/homebrew/bin/ffprobe",
163 "-v", "error", "-select_streams", "v:0", "-count_frames",
164 "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path)
165 videoOut, err := videoCmd.Output()
166 if err != nil {
167 return fmt.Errorf("failed to count video frames: %w", err)
168 }
169 videoFrames, err := strconv.Atoi(strings.Split(strings.TrimSpace(string(videoOut)), "\n")[0])
170 if err != nil {
171 return fmt.Errorf("failed to parse video frame count: %w", err)
172 }
173
174 // Count AAC frames
175 audioCmd := exec.Command("/opt/homebrew/bin/ffprobe",
176 "-v", "error", "-select_streams", "a:0", "-count_frames",
177 "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path)
178 audioOut, err := audioCmd.Output()
179 if err != nil {
180 return fmt.Errorf("failed to count audio frames: %w", err)
181 }
182 aacFrames, err := strconv.Atoi(strings.Split(strings.TrimSpace(string(audioOut)), "\n")[0])
183 if err != nil {
184 return fmt.Errorf("failed to parse audio frame count: %w", err)
185 }
186
187 // Validate
188 if videoFrames != expectedVideoFrames {
189 return fmt.Errorf("invalid video frame count: got %d, expected %d", videoFrames, expectedVideoFrames)
190 }
191 if aacFrames < expectedAACFrames-5 || aacFrames > expectedAACFrames+5 {
192 return fmt.Errorf("invalid audio frame count: got %d, expected ~%d", aacFrames, expectedAACFrames)
193 }
194
195 return nil
196}
197
198func listVideos() (videos []string, err error) {
199 vids, err := filepath.Glob(filepath.Join(outdir, "*.mkv"))
200 if err != nil {
201 return nil, err
202 }
203 for _, vid := range vids {
204 videos = append(videos, filepath.Base(vid))
205 }
206 return videos, nil
207}