mirror of
https://github.com/charmbracelet/vhs.git
synced 2025-11-08 23:05:05 -06:00
329 lines
7.5 KiB
Go
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
|
|
}
|