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
at main 207 lines 5.9 kB view raw
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}