From 836f070df11209a3da5f31ab83b437e3a8e902e2 Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Sun, 7 Sep 2025 12:01:40 -0400 Subject: [PATCH] Started the translation from my old convert.js to the new thing. --- .gitignore | 30 +++++++ Makefile | 8 ++ config.toml | 10 +++ config/settings.go | 56 +++++++++++++ convert.js | 190 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 13 ++++ go.sum | 8 ++ main.go | 21 +++++ 8 files changed, 336 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 config.toml create mode 100644 config/settings.go create mode 100644 convert.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ff5ff0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# ---> Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +backup +*.exe +*.dll +coverage +coverage/* +.venv +*.gz +public +*.mp4 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c15d0ac --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +GO_IS_STUPID_EXE= + +ifeq '$(OS)' 'Windows_NT' + GO_IS_STUPID_EXE=.exe +endif + +build: + go build . diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..65e3b79 --- /dev/null +++ b/config.toml @@ -0,0 +1,10 @@ +Scale = "1280:720" +VideoBitrate = 900 +AudioBitrate = 192 +Speed = "veryslow" +CleanFilename = false +CRF = 20 +FPS = 30 +Tune = "animation" +Input = "test.mp4" +Output = "test_render.mp4" diff --git a/config/settings.go b/config/settings.go new file mode 100644 index 0000000..7cc3b8f --- /dev/null +++ b/config/settings.go @@ -0,0 +1,56 @@ +package config + +import ( + "flag" + "log" + "github.com/BurntSushi/toml" +) + +type config struct { + Test int + TestStart int + InitOutdir bool + Debug int + Progress bool + ConfigPath string + + Scale string + VideoBitrate int + AudioBitrate int + Speed string + CleanFilename bool + CRF int + FPS int + Tune string + Input string + Output string +} + +var Settings config + +func parseFlags(c *config) { + flag.IntVar(&c.Test, "test", 10, "Make a test video seconds long.") + flag.IntVar(&c.TestStart, "test-start", 60, "When to start the test clip.") + flag.BoolVar(&c.InitOutdir, "init-outdir", false, "if outdir doesn't exist create it") + flag.IntVar(&c.Debug, "debug", 0, "1=print the ffmpeg command, 2=and its stderr output") + flag.BoolVar(&c.Progress, "progress", false, "Show percent progress. Not accurate (thanks ffmpeg)") + flag.StringVar(&c.ConfigPath, "config", "config.toml", "config.toml to load") + + flag.Parse() +} + +func Load() { + parseFlags(&Settings) + + metadata, err := toml.DecodeFile(Settings.ConfigPath, &Settings) + + if err != nil { + log.Fatalf("error loading config.toml: %v", err) + } + + bad_keys := metadata.Undecoded() + + if len(bad_keys) > 0 { + log.Fatalf("unknown configuration keys: %v", bad_keys); + } +} diff --git a/convert.js b/convert.js new file mode 100644 index 0000000..e871244 --- /dev/null +++ b/convert.js @@ -0,0 +1,190 @@ +import path from "path"; +import ffmpeg from "fluent-ffmpeg"; +import { glob, mkdir } from "../lib/builderator.js"; +import { defer } from "../lib/api.js"; +import { existsSync as exists } from "fs"; +import assert from "assert"; + +import logging from "../lib/logging.js"; +const log = logging.create("commands/convert.js"); + +const dev_null = process.platform == "win32" ? "NUL" : "/dev/null"; + +export const description = "Converts videos from anything to .mp4."; + +export const options = [ + ["--scale ", "720, 1080, or 2160 for example. A : will be a literal ffmpeg scale.", "1920:1080"], + ["--test ", "Make a test video seconds long."], + ["--test-start ", "When to start the test clip."], + ["--init-outdir", "if outdir doesn't exist create it", false], + ["--br-video ", "video bitrate", 900], + ["--br-audio ", "video bitrate", 192], + ["--speed ", "ffmpeg speed preset", "veryslow"], + ["--crf ", "constant rate factor", 30], + ["--fps ", "target fps", 30], + ["--tune ", "special tuning setting for mp4 try film", "animation"], + ["--debug ", "1=print the ffmpeg command, 2=and its stderr output"], + ["--clean-filename", "don't modify the output file with scale info", false], + ["--progress", "Show percent progress. Not accurate (thanks ffmpeg)", false], + ["--output ", "specific output file name"], + ["--outdir ", "write the file to this dir (can't combine with output)"], + ["--vp9", "encode with vp9"], +]; + +export const required = [ + ["--input [string]", "input file name or glob/regex"], +]; + +const generate_output_file = (opts) => { + if(opts.output) { + return opts.output; + } else { + const { dir, name } = path.parse(opts.input); + const ext = opts.vp9 ? "webm" : "mp4"; + const target_dir = opts.outdir ? opts.outdir : dir; + + if(opts.initOutdir) mkdir(target_dir); + + assert(exists(target_dir), `Target directory ${target_dir} does not exist.`); + + if(opts.cleanFilename) { + return path.join(target_dir, `${name}.${ext}`); + } else { + return path.join(target_dir, `${name}.${opts.scale.replace(':','_')}.${ext}`); + } + } +} + +const run = async (pass, output, opts) => { + const encode_defer = defer(); + // adapt the scale so a literal one can be used in weird situations + const scale = opts.scale.includes(":") ? opts.scale : `-1:${opts.scale}`; + + // taken from https://developers.google.com/media/vp9/settings/vod + + + // 22m without these, 13m with these + const vp9_opts = [ + ["-pass", pass], + ["-passlogfile", `ffmpeg2pass-${ process.pid }`], + ["-minrate", `${opts.brVideo / 2 }k`], + ["-maxrate", `${opts.brVideo * 1.45 }k`], + ["-quality", "good"], + ["-speed", pass == 1 ? 4 : 2], + ["-threads", "8"], + // this is different for 1080 videos vs 720 + ["-tile-columns", pass + 1], + // key frames at 240 + ["-g", "240"], + ]; + + const mp4_opts = [ + ["-vf", `scale=${scale}:flags=lanczos`], + ["-aspect", `${scale}`], + ["-pix_fmt","yuv420p"], + ["-tune", opts.tune], + ["-movflags", "faststart"], + ["-pass", pass], + ["-passlogfile", `ffmpeg2pass-${ process.pid }.log`], + ["-preset", opts.speed], + ["-filter:v", `fps=${opts.fps}`], + ]; + + if(opts.crf) { + vp9_opts.push(["-crf", opts.crf]); + mp4_opts.push(["-crf", opts.crf]); + } + + let encode = ffmpeg(opts.input, {logger: log}) + .inputOptions([]); + + if(opts.testStart || opts.test) { + encode.seek(opts.testStart || opts.test); + } + + // this logic is too convoluted + if((opts.vp9 && pass < 2) || (!opts.vp9 && pass < 3)) { + encode.noAudio(); + } else { + encode.audioBitrate(opts.brAudio); + } + + // we have to use flat here because weirdly ffmpeg-fluent expects them in a big list + encode.videoBitrate(opts.brVideo) + .outputOptions(opts.vp9 ? vp9_opts.flat() : mp4_opts.flat()) + .output(output) + + if(opts.vp9) { + encode.audioCodec("libopus") + .videoCodec("libvpx-vp9") + .format("webm"); + } else { + encode.audioCodec("aac") + .videoCodec("libx264") + .format("mp4"); + } + + if(opts.test) { + encode.duration(opts.test); + } + + if(opts.progress) { + encode.on("progress", (progress) => { + process.stdout.write(`${path.basename(opts.input)} -> ${ output } ${Math.round(progress.percent)}% \r`) + }); + } + + if(opts.debug) { + encode.on("start", line => console.log("FFMPEG: ", line)); + + if(opts.debug == 2) { + encode.on("stderr", line => console.log(line)); + } + } + + encode.on("end", () => encode_defer.resolve()); + + console.log("------ PASS", pass, opts.input); + encode.run(); + + return encode_defer; +} + +export const process_file = async (opts) => { + const output = generate_output_file(opts); + + if(exists(output)) { + console.log("Skipping output", output); + } else { + if(opts.vp9) { + await run(1, dev_null, opts); + await run(2, output, opts); + } else { + await run(1, dev_null, opts); + await run(2, dev_null, opts); + await run(3, output, opts); + } + } +} + +export const main = async (opts) => { + const inputs = glob(opts.input); + + if(inputs.length === 0) { + console.error(`ERROR: Glob --input ${opts.input} didn't match any files.`); + process.exit(1); + } + + for(let fname of inputs) { + opts.input = fname; + try { + await process_file(opts); + } catch(e) { + console.log("--------------------------------------------------------------------") + console.error(e, `Processing file ${fname}`); + console.log("--------------------------------------------------------------------") + } + } + + process.exit(0); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3e97d9d --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module lcthw.dev/vidcrunch + +go 1.24.2 + +require ( + github.com/BurntSushi/toml v1.5.0 + github.com/modfy/fluent-ffmpeg v0.1.0 +) + +require ( + github.com/fatih/structs v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b2a71b --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/modfy/fluent-ffmpeg v0.1.0 h1:9T191rhSK6KfoDo9Y/+0Tph3khrudvLQEEi05O+ijHA= +github.com/modfy/fluent-ffmpeg v0.1.0/go.mod h1:GauXGqGYAmYFupCWG8n1eyuLZMKmLxGTGvszYkJ0Oyo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..bbd836d --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "lcthw.dev/vidcrunch/config" + "github.com/modfy/fluent-ffmpeg" +) + + +func main() { + config.Load() + fmt.Println(config.Settings) + + err := fluentffmpeg.NewCommand(""). + InputPath(config.Settings.Input). + OutputFormat("mp4"). + OutputPath(config.Settings.Output). + Run() + + if err != nil { panic("fail") } +}