vhs/ffmpeg.go
Carlos Alexandro Becker 710bb769af
fix: lint (#637)
2025-06-17 20:49:26 +02:00

329 lines
7.5 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// FilterComplexBuilder generates -filter_complex option of ffmepg.
type FilterComplexBuilder struct {
filterComplex *strings.Builder
style *StyleOptions
termWidth int
termHeight int
prevStageName string
}
// NewVideoFilterBuilder returns instance of FilterComplexBuilder with video config.
func NewVideoFilterBuilder(videoOpts *VideoOptions) *FilterComplexBuilder {
filterCode := strings.Builder{}
termWidth, termHeight := calcTermDimensions(*videoOpts.Style)
filterCode.WriteString(
fmt.Sprintf(`
[0][1]overlay[merged];
[merged]scale=%d:%d:force_original_aspect_ratio=1[scaled];
[scaled]fps=%d,setpts=PTS/%f[speed];
[speed]pad=%d:%d:(ow-iw)/2:(oh-ih)/2:%s[padded];
[padded]fillborders=left=%d:right=%d:top=%d:bottom=%d:mode=fixed:color=%s[padded]
`,
termWidth-double(videoOpts.Style.Padding),
termHeight-double(videoOpts.Style.Padding),
videoOpts.Framerate,
videoOpts.PlaybackSpeed,
termWidth,
termHeight,
videoOpts.Style.BackgroundColor,
videoOpts.Style.Padding,
videoOpts.Style.Padding,
videoOpts.Style.Padding,
videoOpts.Style.Padding,
videoOpts.Style.BackgroundColor,
),
)
return &FilterComplexBuilder{
filterComplex: &filterCode,
termHeight: termHeight,
termWidth: termWidth,
style: videoOpts.Style,
prevStageName: "padded",
}
}
// NewScreenshotFilterComplexBuilder returns instance of FilterComplexBuilder with screenshot config.
func NewScreenshotFilterComplexBuilder(style *StyleOptions) *FilterComplexBuilder {
filterCode := strings.Builder{}
termWidth, termHeight := calcTermDimensions(*style)
filterCode.WriteString(
fmt.Sprintf(`
[0][1]overlay[merged];
[merged]scale=%d:%d:force_original_aspect_ratio=1[scaled];
[scaled]pad=%d:%d:(ow-iw)/2:(oh-ih)/2:%s[padded];
[padded]fillborders=left=%d:right=%d:top=%d:bottom=%d:mode=fixed:color=%s[padded]
`,
termWidth-double(style.Padding),
termHeight-double(style.Padding),
termWidth,
termHeight,
style.BackgroundColor,
style.Padding,
style.Padding,
style.Padding,
style.Padding,
style.BackgroundColor,
),
)
return &FilterComplexBuilder{
filterComplex: &filterCode,
termHeight: termHeight,
termWidth: termWidth,
style: style,
prevStageName: "padded",
}
}
// calcTermDimensions computes terminal dimensions.
// It returns width and height values.
func calcTermDimensions(style StyleOptions) (int, int) {
width := style.Width
height := style.Height
if style.MarginFill != "" {
width = width - double(style.Margin)
height = height - double(style.Margin)
}
if style.WindowBar != "" {
height = height - style.WindowBarSize
}
return width, height
}
// WithWindowBar adds window bar options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithWindowBar(barStream int) *FilterComplexBuilder {
if fb.style.WindowBar != "" {
fb.filterComplex.WriteString(";")
_, _ = fmt.Fprintf(
fb.filterComplex,
`
[%d]loop=-1[loopbar];
[loopbar][%s]overlay=0:%d[withbar]
`,
barStream,
fb.prevStageName,
fb.style.WindowBarSize,
)
fb.prevStageName = "withbar"
}
return fb
}
// WithBorderRadius adds border radius options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithBorderRadius(cornerMarkStream int) *FilterComplexBuilder {
if fb.style.BorderRadius != 0 {
fb.filterComplex.WriteString(";")
_, _ = fmt.Fprintf(
fb.filterComplex,
`
[%d]loop=-1[loopmask];
[%s][loopmask]alphamerge[rounded]
`,
cornerMarkStream,
fb.prevStageName,
)
fb.prevStageName = "rounded"
}
return fb
}
// WithMarginFill adds margin options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexBuilder {
// Overlay terminal on margin
if fb.style.MarginFill != "" {
// ffmpeg will complain if the final filter ends with a semicolon,
// so we add one BEFORE we start adding filters.
fb.filterComplex.WriteString(";")
_, _ = fmt.Fprintf(
fb.filterComplex,
`
[%d]scale=%d:%d[bg];
[bg][%s]overlay=(W-w)/2:(H-h)/2:shortest=1[withbg]
`,
marginStream,
fb.style.Width,
fb.style.Height,
fb.prevStageName,
)
fb.prevStageName = "withbg"
}
return fb
}
// WithGIF adds gif options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithGIF() *FilterComplexBuilder {
fb.filterComplex.WriteString(";")
_, _ = fmt.Fprintf(
fb.filterComplex,
`
[%s]split[plt_a][plt_b];
[plt_a]palettegen=max_colors=256[plt];
[plt_b][plt]paletteuse[palette]`,
fb.prevStageName,
)
fb.prevStageName = "palette"
return fb
}
// Build returns filter_complex used in ffmepg.
func (fb *FilterComplexBuilder) Build() []string {
return []string{
"-filter_complex", fb.filterComplex.String(),
"-map", "[" + fb.prevStageName + "]",
}
}
// StreamBuilder generates streams used by ffmepg.
type StreamBuilder struct {
args []string
counter int
style *StyleOptions
termWidth int
termHeight int
input string
barStream int
cornerStream int
marginStream int
}
// NewStreamBuilder returns instance of StreamBuilder.
func NewStreamBuilder(streamCounter int, input string, style *StyleOptions) *StreamBuilder {
termWidth, termHeight := calcTermDimensions(*style)
return &StreamBuilder{
counter: streamCounter,
args: []string{},
style: style,
termWidth: termWidth,
termHeight: termHeight,
input: input,
}
}
// WithMargin adds margin stream.
func (sb *StreamBuilder) WithMargin() *StreamBuilder {
if sb.style.MarginFill != "" {
if marginFillIsColor(sb.style.MarginFill) {
// Create plain color stream
sb.args = append(sb.args,
"-f", "lavfi",
"-i",
fmt.Sprintf(
"color=%s:s=%dx%d",
sb.style.MarginFill,
sb.style.Width,
sb.style.Height,
),
)
} else {
// Check for existence first.
_, err := os.Stat(sb.style.MarginFill)
if err != nil {
fmt.Println(ErrorStyle.Render("Unable to read margin file: "), sb.style.MarginFill)
}
// Add image stream
sb.args = append(sb.args,
"-loop", "1",
"-i", sb.style.MarginFill,
)
}
sb.marginStream = sb.counter
sb.counter++
}
return sb
}
// WithBar adds bar stream.
func (sb *StreamBuilder) WithBar() *StreamBuilder {
barPath := filepath.Join(sb.input, "bar.png")
if sb.style.WindowBar != "" {
MakeWindowBar(sb.termWidth, sb.termHeight, *sb.style, barPath)
sb.args = append(sb.args,
"-i", barPath,
)
sb.barStream = sb.counter
sb.counter++
}
return sb
}
// WithCorner adds corner stream.
func (sb *StreamBuilder) WithCorner() *StreamBuilder {
maskPath := filepath.Join(sb.input, "mask.png")
if sb.style.BorderRadius != 0 {
if sb.style.WindowBar != "" {
MakeBorderRadiusMask(sb.termWidth, sb.termHeight+sb.style.WindowBarSize, sb.style.BorderRadius, maskPath)
} else {
MakeBorderRadiusMask(sb.termWidth, sb.termHeight, sb.style.BorderRadius, maskPath)
}
sb.args = append(sb.args,
"-i", maskPath,
)
sb.cornerStream = sb.counter
sb.counter++
}
return sb
}
// WithMP4 adds mp4 stream with required config.
func (sb *StreamBuilder) WithMP4() *StreamBuilder {
sb.args = append(sb.args,
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-an",
"-crf", "20",
)
return sb
}
// WithWebm adds webm stream with required config.
func (sb *StreamBuilder) WithWebm() *StreamBuilder {
sb.args = append(sb.args,
"-pix_fmt", "yuv420p",
"-an",
"-crf", "30",
"-b:v", "0",
)
return sb
}
// Build returns streams for using with ffmepg.
func (sb *StreamBuilder) Build() []string {
return sb.args
}