A simple little command line tool in Go to crunch my videos. This includes many settings I've found that compress "code videos" really well. YMMV.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
vidcrunch/main.go

260 lines
6.2 KiB

package main
import (
"fmt"
"path/filepath"
"runtime"
"log"
"os"
"math/rand"
"net/http"
"strings"
"lcthw.dev/go/vidcrunch/config"
"github.com/modfy/fluent-ffmpeg"
"strconv"
"os/exec"
"slices"
"iter"
)
func AudioOnly(codec config.CodecOpts) bool {
return codec.Scale == "" && codec.VideoCodec == "none"
}
func VideoOnly(codec config.CodecOpts) bool {
return codec.AudioCodec == "none"
}
func ExtractBase(source_path string, outdir string) (string, string) {
base := filepath.Base(source_path)
target := filepath.Join(outdir, base)
cleaned := filepath.Clean(target)
dir, file := filepath.Split(cleaned)
ext := filepath.Ext(file)
base, found := strings.CutSuffix(file, ext)
if !found { panic("no extension found?!") }
return dir, base
}
func SetCodecTarget(codec *config.CodecOpts, source_path string, encoding config.EncodeOpts) {
dir, base := ExtractBase(source_path, encoding.OutDir)
dot_or_dash := "."; if encoding.Dash { dot_or_dash = "/" }
if AudioOnly(*codec) {
renamed := fmt.Sprint(base, dot_or_dash, "audio.", encoding.Format)
codec.Target = filepath.Join(dir, renamed)
} else {
dim := strings.Replace(codec.Scale, ":", ".", 1)
renamed := fmt.Sprint(base, dot_or_dash, dim, ".", encoding.Format)
codec.Target = filepath.Join(dir, renamed)
}
codec.Target = filepath.ToSlash(codec.Target)
}
func Run(encoding config.EncodeOpts, codec config.CodecOpts, pass int, pid int, input string, output string) {
encode := fluentffmpeg.NewCommand("")
extras := []string{
"-pix_fmt", "yuv420p",
}
if encoding.Passes > 1 {
extras = append(extras,
"-pass", fmt.Sprint(pass),
"-passlogfile", fmt.Sprintf("ffmpegpass-%x.log", pid))
}
if encoding.Preset != "" {
extras = append(extras, "-preset", encoding.Preset)
}
if codec.Resize {
extras = append(extras,
"-vf", fmt.Sprintf("scale=%s:flags=lanczos", codec.Scale),
"-aspect", codec.Scale)
}
if pass != encoding.Passes || VideoOnly(codec) {
extras = append(extras, "-an")
} else {
encode.AudioCodec(codec.AudioCodec)
extras = append(extras,
"-b:a", fmt.Sprint(codec.AudioBitrate * 1024))
}
if encoding.Test > 0 {
encode.InputOptions("-ss", fmt.Sprintf("00:%d", encoding.TestStart))
extras = append(extras, "-t", fmt.Sprint(encoding.Test))
}
if codec.VideoCodec == "none" {
extras = append(extras, "-vn")
} else {
encode.VideoCodec(codec.VideoCodec).
VideoBitRate(codec.VideoBitrate * 1024).
FrameRate(encoding.FPS).
ConstantRateFactor(encoding.CRF)
}
extras = append(extras, encoding.Extras...)
if encoding.Dash {
extras = append(extras, "-dash", "1")
}
encode.OutputOptions(extras...)
cmd := encode.InputPath(input).
OutputFormat(encoding.Format).
OutputPath(output).
OutputLogs(os.Stdout).
Overwrite(true).
Build()
fmt.Println("====== FFMPEG ======\n", cmd.String(), "\n============")
err := cmd.Run()
if err != nil { log.Fatalf("%v", err) }
}
func DevNull() string {
if runtime.GOOS == "windows" {
return "NUL"
} else {
return "/dev/null"
}
}
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func RenderFile(encoding config.EncodeOpts, pid int, source_path string, force bool) {
for i := 0; i < len(encoding.Variants); i++ {
codec := &encoding.Variants[i]
SetCodecTarget(codec, source_path, encoding)
// ensure the target directories exist
target_dir := filepath.Dir(codec.Target)
if !FileExists(codec.Target) {
err := os.MkdirAll(target_dir, 0750)
if err != nil {
log.Fatal("failed to make target path: %s: %v", target_dir, err)
}
}
if FileExists(codec.Target) && !force {
fmt.Println("^^^ SKIP", source_path, "->", codec.Target)
return
}
fmt.Println("--- PATH", source_path, "->", codec.Target)
for i := 1; i < encoding.Passes; i++ {
Run(encoding, *codec, i, pid, source_path, DevNull())
}
Run(encoding, *codec, encoding.Passes, pid, source_path, codec.Target)
}
SaveMPD(source_path, encoding)
}
func StrSeq(up_to int) iter.Seq[string] {
return func (yield func(x string) bool) {
for i := range up_to {
if !yield(strconv.Itoa(i)) { return }
}
}
}
func SaveMPD(source_path string, encoding config.EncodeOpts) {
args := make([]string, 0, 10)
var the_audio string
// force it to overwrite
args = append(args, "-y")
for _, codec := range encoding.Variants {
if VideoOnly(codec) {
args = append(args, "-f", "webm_dash_manifest", "-i", codec.Target)
} else if AudioOnly(codec) {
the_audio = codec.Target
}
}
args = append(args, "-f", "webm_dash_manifest", "-i", the_audio)
// create map for each
args = append(args, "-c", "copy")
for i := 0; i < len(encoding.Variants); i++ {
args = append(args, "-map", strconv.Itoa(i))
}
// generate adaptation sets, separating the audio
// id=0 is videos, id=1 is audio
args = append(args, "-f", "webm_dash_manifest", "-adaptation_sets")
da_ints := slices.Collect(StrSeq(len(encoding.Variants) - 1))
video_set := strings.Join(da_ints, ",")
adapt_set := fmt.Sprintf("id=0,streams=%s id=1,streams=%d",
video_set, len(encoding.Variants) - 1)
args = append(args, adapt_set)
dir, base := ExtractBase(source_path, encoding.OutDir)
mpd_path := filepath.ToSlash(filepath.Join(dir, base, "manifest.mpd"))
args = append(args, mpd_path)
ffmpeg := exec.Command("ffmpeg", args...)
fmt.Println("==== MPD COMMAND ====\n", ffmpeg.String(), "\n==============")
out, err := ffmpeg.CombinedOutput()
os.Stdout.Write(out)
log.Fatal(err)
}
func RenderToDir(encoding config.EncodeOpts, force bool) {
matches, err := filepath.Glob(encoding.Input)
if err != nil { log.Fatalf("%v", err) }
for _, source_path := range matches {
RenderFile(encoding, rand.Int(), source_path, force)
}
}
func main() {
settings := config.Load()
if settings.Serve != "" {
dir_handler := http.FileServer(http.Dir(settings.Serve))
http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Method, r.URL)
dir_handler.ServeHTTP(w, r)
})
log.Fatal(http.ListenAndServe(settings.Port, nil))
} else {
for i := 0; i < len(settings.Encodings); i++ {
encoding := &settings.Encodings[i];
if encoding.OutDir != "" {
RenderToDir(*encoding, settings.Force)
} else {
log.Fatal("config file needs either Output or OutDir")
}
}
}
}