759 lines
23 KiB
Go
759 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type ExportClip struct {
|
|
Src string `json:"src"`
|
|
In float64 `json:"in"`
|
|
Out float64 `json:"out"`
|
|
}
|
|
|
|
type ExportRequest struct {
|
|
Clips []ExportClip `json:"clips"`
|
|
Mode string `json:"mode"` // copy | encode | gif
|
|
Join bool `json:"join"` // one file, or one file per clip
|
|
Output string `json:"output"`
|
|
Overwrite bool `json:"overwrite"`
|
|
NoAudio bool `json:"noAudio"`
|
|
// stream copy
|
|
AllAudio bool `json:"allAudio"`
|
|
// re-encode
|
|
VCodec string `json:"vcodec"`
|
|
Quality int `json:"quality"`
|
|
Speed string `json:"speed"` // fast | balanced | slow
|
|
Height int `json:"height"` // 0 = keep
|
|
FPS string `json:"fps"` // "" = keep
|
|
ACodec string `json:"acodec"`
|
|
ABitrate int `json:"abitrate"` // kbps
|
|
// animated GIF (also uses Height and FPS)
|
|
Dither string `json:"dither"` // one of gifDithers
|
|
Loop bool `json:"loop"`
|
|
}
|
|
|
|
// gifDithers are the paletteuse dither modes offered for GIF export.
|
|
var gifDithers = []string{"sierra2_4a", "bayer", "none"}
|
|
|
|
type clipJob struct {
|
|
m *Media
|
|
in, out float64
|
|
}
|
|
|
|
func (c clipJob) dur() float64 { return c.out - c.in }
|
|
|
|
func (a *App) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
if !a.tools.OK() {
|
|
writeError(w, http.StatusServiceUnavailable, "ffmpeg/ffprobe not found")
|
|
return
|
|
}
|
|
var req ExportRequest
|
|
if err := readJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if a.jobs.AnyRunning("export") {
|
|
writeError(w, http.StatusConflict, "an export is already running")
|
|
return
|
|
}
|
|
clips, err := a.resolveClips(req.Clips)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
out, err := filepath.Abs(strings.TrimSpace(req.Output))
|
|
if err != nil || strings.TrimSpace(req.Output) == "" {
|
|
writeError(w, http.StatusBadRequest, "choose where to save the export")
|
|
return
|
|
}
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(out), "."))
|
|
if st, err := os.Stat(filepath.Dir(out)); err != nil || !st.IsDir() {
|
|
writeError(w, http.StatusBadRequest, "the output folder does not exist: "+filepath.Dir(out))
|
|
return
|
|
}
|
|
|
|
switch req.Mode {
|
|
case "copy":
|
|
if !slices.Contains(copyExts, ext) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported output type %q for lossless export", "."+ext))
|
|
return
|
|
}
|
|
if req.Join && len(clips) > 1 {
|
|
if msg := copyCompatProblem(clips, req.AllAudio && !req.NoAudio); msg != "" {
|
|
writeError(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
}
|
|
case "encode":
|
|
if err := a.validateEncode(&req, ext); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
case "gif":
|
|
if err := validateGIF(&req, ext); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unknown export mode")
|
|
return
|
|
}
|
|
|
|
outputs := []string{out}
|
|
if !req.Join {
|
|
outputs = numberedOutputs(out, len(clips))
|
|
}
|
|
for _, o := range outputs {
|
|
for _, c := range clips {
|
|
if samePath(o, c.m.Path) {
|
|
writeError(w, http.StatusBadRequest, "the output would overwrite a source video: "+o)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if !req.Overwrite {
|
|
var exists []string
|
|
for _, o := range outputs {
|
|
if _, err := os.Stat(o); err == nil {
|
|
exists = append(exists, o)
|
|
}
|
|
}
|
|
if len(exists) > 0 {
|
|
writeErrorData(w, http.StatusConflict, "output file already exists", map[string]any{"exists": exists})
|
|
return
|
|
}
|
|
}
|
|
|
|
title := filepath.Base(out)
|
|
if !req.Join {
|
|
title = fmt.Sprintf("%d files", len(outputs))
|
|
}
|
|
log.Printf("export started: %d clip(s), mode=%s, join=%v -> %s", len(clips), req.Mode, req.Join, out)
|
|
j := a.jobs.Start("export", title, "", func(ctx context.Context, c *JobCtl) error {
|
|
return a.runExport(ctx, c, &req, clips, outputs)
|
|
})
|
|
writeJSON(w, map[string]any{"jobId": j.ID, "outputs": outputs})
|
|
}
|
|
|
|
func (a *App) resolveClips(in []ExportClip) ([]clipJob, error) {
|
|
if len(in) == 0 {
|
|
return nil, fmt.Errorf("there are no clips to export")
|
|
}
|
|
var out []clipJob
|
|
for i, c := range in {
|
|
m := a.media.Get(c.Src)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("clip %d: source video is not loaded", i+1)
|
|
}
|
|
cin := math.Max(0, c.In)
|
|
cout := math.Min(c.Out, m.Duration)
|
|
if cout-cin < 0.01 {
|
|
return nil, fmt.Errorf("clip %d is empty (in %.3f, out %.3f)", i+1, c.In, c.Out)
|
|
}
|
|
out = append(out, clipJob{m: m, in: cin, out: cout})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// numberedOutputs turns "C:\v\name.mp4" into name_01.mp4, name_02.mp4, ...
|
|
func numberedOutputs(out string, n int) []string {
|
|
ext := filepath.Ext(out)
|
|
base := strings.TrimSuffix(out, ext)
|
|
width := len(strconv.Itoa(n))
|
|
if width < 2 {
|
|
width = 2
|
|
}
|
|
paths := make([]string, n)
|
|
for i := range paths {
|
|
paths[i] = fmt.Sprintf("%s_%0*d%s", base, width, i+1, ext)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func samePath(a, b string) bool {
|
|
a, b = filepath.Clean(a), filepath.Clean(b)
|
|
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
|
return strings.EqualFold(a, b)
|
|
}
|
|
return a == b
|
|
}
|
|
|
|
// copyCompatProblem explains why clips from different sources cannot be
|
|
// joined without re-encoding, or returns "".
|
|
func copyCompatProblem(clips []clipJob, allAudio bool) string {
|
|
sig := func(m *Media) string {
|
|
var parts []string
|
|
if v := m.Video; v != nil {
|
|
parts = append(parts, fmt.Sprintf("video %s %s %dx%d %s", v.Codec, v.Profile, v.Width, v.Height, v.PixFmt))
|
|
} else {
|
|
parts = append(parts, "no video")
|
|
}
|
|
audio := m.Audio
|
|
if !allAudio && len(audio) > 1 {
|
|
audio = audio[:1]
|
|
}
|
|
for _, s := range audio {
|
|
parts = append(parts, fmt.Sprintf("audio %s %dHz %dch", s.Codec, s.SampleRate, s.Channels))
|
|
}
|
|
if len(audio) == 0 {
|
|
parts = append(parts, "no audio")
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
first := clips[0].m
|
|
want := sig(first)
|
|
for _, c := range clips[1:] {
|
|
if c.m == first {
|
|
continue
|
|
}
|
|
if got := sig(c.m); got != want {
|
|
return fmt.Sprintf("Lossless join needs all sources to have the same format, but they differ:\n• %s: %s\n• %s: %s\nUse Re-encode, or export each clip separately.",
|
|
first.Name, want, c.m.Name, got)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *App) validateEncode(req *ExportRequest, ext string) error {
|
|
ct, ok := containerByExt(ext)
|
|
if !ok {
|
|
return fmt.Errorf("re-encoding supports .mp4, .mkv, .mov and .webm outputs, not %q", "."+ext)
|
|
}
|
|
vc, ok := codecByName(req.VCodec)
|
|
if !ok || vc.Kind != "video" || !a.tools.HasEncoder(vc.Name) {
|
|
return fmt.Errorf("video encoder %q is not available", req.VCodec)
|
|
}
|
|
if !slices.Contains(ct.Video, vc.Family) {
|
|
return fmt.Errorf("%s cannot contain %s video", ct.Label, vc.Label)
|
|
}
|
|
if req.ACodec == "" || req.ACodec == "none" {
|
|
req.NoAudio = true
|
|
}
|
|
if !req.NoAudio {
|
|
ac, ok := codecByName(req.ACodec)
|
|
if !ok || ac.Kind != "audio" || !a.tools.HasEncoder(ac.Name) {
|
|
return fmt.Errorf("audio encoder %q is not available", req.ACodec)
|
|
}
|
|
if !slices.Contains(ct.Audio, ac.Family) {
|
|
return fmt.Errorf("%s cannot contain %s audio", ct.Label, ac.Label)
|
|
}
|
|
}
|
|
if req.Quality < 0 || req.Quality > vc.MaxQ {
|
|
req.Quality = vc.DefQ
|
|
}
|
|
if req.Height < 0 || req.Height > 8640 {
|
|
return fmt.Errorf("invalid output height %d", req.Height)
|
|
}
|
|
if req.FPS != "" && parseRatio(req.FPS, "/") <= 0 {
|
|
return fmt.Errorf("invalid frame rate %q", req.FPS)
|
|
}
|
|
if req.ABitrate <= 0 || req.ABitrate > 1024 {
|
|
req.ABitrate = 192
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateGIF(req *ExportRequest, ext string) error {
|
|
if ext != "gif" {
|
|
return fmt.Errorf("animated GIF export needs a .gif output, not %q", "."+ext)
|
|
}
|
|
if req.Height < 0 || req.Height > 8640 {
|
|
return fmt.Errorf("invalid output height %d", req.Height)
|
|
}
|
|
// GIF frame delays are whole centiseconds, and browsers slow down
|
|
// anything faster than 50 fps.
|
|
if fps := parseRatio(req.FPS, "/"); fps <= 0 || fps > 50 {
|
|
return fmt.Errorf("invalid GIF frame rate %q (up to 50 fps)", req.FPS)
|
|
}
|
|
if !slices.Contains(gifDithers, req.Dither) {
|
|
req.Dither = gifDithers[0]
|
|
}
|
|
req.NoAudio = true
|
|
return nil
|
|
}
|
|
|
|
func (a *App) runExport(ctx context.Context, c *JobCtl, req *ExportRequest, clips []clipJob, outputs []string) error {
|
|
var total float64
|
|
for _, cl := range clips {
|
|
total += cl.dur()
|
|
}
|
|
copyJoin := req.Mode == "copy" && req.Join && len(clips) > 1
|
|
if copyJoin {
|
|
total *= 2 // cutting pass + joining pass
|
|
}
|
|
done := 0.0
|
|
progress := func(base, limit float64) func(float64, string) {
|
|
return func(sec float64, speed string) {
|
|
c.Progress((base+math.Min(sec, limit))/total, speed)
|
|
}
|
|
}
|
|
// run writes to a temporary name and renames on success, so a failed or
|
|
// canceled export never leaves a broken file (or destroys an old one).
|
|
run := func(args []string, out string, dur float64) error {
|
|
tmp := partialName(out)
|
|
args = append(args, tmp)
|
|
err := a.tools.runFFmpeg(ctx, args, progress(done, dur))
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
done += dur
|
|
if err := os.Rename(tmp, out); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
keyframes := map[*Media][]float64{}
|
|
if req.Mode == "copy" {
|
|
c.Step("Reading keyframes")
|
|
for _, cl := range clips {
|
|
if _, ok := keyframes[cl.m]; ok {
|
|
continue
|
|
}
|
|
kfs, err := a.media.Keyframes(ctx, cl.m)
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err != nil {
|
|
log.Printf("keyframe scan failed for %s, cutting without it: %v", cl.m.Name, err)
|
|
}
|
|
keyframes[cl.m] = kfs
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case copyJoin:
|
|
out := outputs[0]
|
|
tmpDir, err := os.MkdirTemp(filepath.Dir(out), ".vibedit-tmp-")
|
|
if err != nil {
|
|
return fmt.Errorf("could not create a temporary folder next to the output: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
ext := filepath.Ext(out)
|
|
var list strings.Builder
|
|
for i, cl := range clips {
|
|
c.Step(fmt.Sprintf("Cutting clip %d of %d", i+1, len(clips)))
|
|
name := fmt.Sprintf("seg_%04d%s", i+1, ext)
|
|
seg := filepath.Join(tmpDir, name)
|
|
args := append(copyClipArgs(cl, req, keyframes[cl.m]), copyOutputArgs(ext, false)...)
|
|
if err := a.tools.runFFmpeg(ctx, append(args, seg), progress(done, cl.dur())); err != nil {
|
|
return fmt.Errorf("clip %d: %w", i+1, err)
|
|
}
|
|
done += cl.dur()
|
|
fmt.Fprintf(&list, "file '%s'\n", name)
|
|
}
|
|
listPath := filepath.Join(tmpDir, "list.txt")
|
|
if err := os.WriteFile(listPath, []byte(list.String()), 0o644); err != nil {
|
|
return err
|
|
}
|
|
c.Step("Joining clips")
|
|
args := ffmpegBaseArgs()
|
|
args = append(args, "-f", "concat", "-safe", "0", "-i", listPath, "-map", "0", "-c", "copy")
|
|
args = append(args, copyOutputArgs(ext, true)...)
|
|
if err := run(args, out, total/2); err != nil {
|
|
return err
|
|
}
|
|
c.AddOutput(out)
|
|
|
|
case req.Mode == "copy":
|
|
for i, cl := range clips {
|
|
c.Step(stepName("Saving", i, len(clips)))
|
|
args := append(copyClipArgs(cl, req, keyframes[cl.m]), copyOutputArgs(filepath.Ext(outputs[i]), true)...)
|
|
if err := run(args, outputs[i], cl.dur()); err != nil {
|
|
return clipErr(err, i, len(clips))
|
|
}
|
|
c.AddOutput(outputs[i])
|
|
}
|
|
|
|
case req.Mode == "gif":
|
|
tmpDir, err := os.MkdirTemp("", "vibedit-gif-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
palette := filepath.Join(tmpDir, "palette.png")
|
|
groups := [][]clipJob{clips}
|
|
if !req.Join {
|
|
groups = nil
|
|
for _, cl := range clips {
|
|
groups = append(groups, []clipJob{cl})
|
|
}
|
|
}
|
|
for i, g := range groups {
|
|
of := ""
|
|
if len(groups) > 1 {
|
|
of = fmt.Sprintf(" (clip %d of %d)", i+1, len(groups))
|
|
}
|
|
paletteRun, gifRun, err := gifArgs(g, req, palette)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// ffmpeg reports no progress while it builds a palette, so like
|
|
// the keyframe scan this step does not move the progress bar.
|
|
c.Step("Building the color palette" + of)
|
|
if err := a.tools.runFFmpeg(ctx, append(paletteRun, palette), nil); err != nil {
|
|
return clipErr(err, i, len(groups))
|
|
}
|
|
c.Step("Encoding GIF" + of)
|
|
var dur float64
|
|
for _, cl := range g {
|
|
dur += cl.dur()
|
|
}
|
|
if err := run(gifRun, outputs[i], dur); err != nil {
|
|
return clipErr(err, i, len(groups))
|
|
}
|
|
c.AddOutput(outputs[i])
|
|
}
|
|
|
|
case req.Join:
|
|
c.Step("Encoding")
|
|
args, err := encodeArgs(clips, req, filepath.Ext(outputs[0]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := run(args, outputs[0], total); err != nil {
|
|
return err
|
|
}
|
|
c.AddOutput(outputs[0])
|
|
|
|
default:
|
|
for i, cl := range clips {
|
|
c.Step(stepName("Encoding", i, len(clips)))
|
|
args, err := encodeArgs([]clipJob{cl}, req, filepath.Ext(outputs[i]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := run(args, outputs[i], cl.dur()); err != nil {
|
|
return clipErr(err, i, len(clips))
|
|
}
|
|
c.AddOutput(outputs[i])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stepName(verb string, i, n int) string {
|
|
if n == 1 {
|
|
return verb
|
|
}
|
|
return fmt.Sprintf("%s clip %d of %d", verb, i+1, n)
|
|
}
|
|
|
|
func clipErr(err error, i, n int) error {
|
|
if n == 1 || err == context.Canceled {
|
|
return err
|
|
}
|
|
return fmt.Errorf("clip %d: %w", i+1, err)
|
|
}
|
|
|
|
// partialName keeps the extension last so ffmpeg still picks the right muxer.
|
|
func partialName(out string) string {
|
|
ext := filepath.Ext(out)
|
|
return strings.TrimSuffix(out, ext) + ".vibedit-part" + ext
|
|
}
|
|
|
|
// copyClipArgs cuts one clip without re-encoding. Stream copy can only start
|
|
// on a keyframe, so the clip starts at the keyframe at or before In. When the
|
|
// keyframe positions are known we seek exactly to it and drop packets before
|
|
// it, so audio does not start ahead of the video.
|
|
func copyClipArgs(cl clipJob, req *ExportRequest, keyframes []float64) []string {
|
|
start := cl.in
|
|
exact := false
|
|
if i := sort.SearchFloat64s(keyframes, cl.in+0.001); i > 0 {
|
|
start, exact = keyframes[i-1], true
|
|
}
|
|
args := ffmpegBaseArgs()
|
|
if exact && start <= 0.001 {
|
|
start = 0
|
|
} else if exact {
|
|
start -= 0.0005 // stay just before the keyframe despite rounding
|
|
args = append(args, "-ss", ffTime(start))
|
|
} else {
|
|
args = append(args, "-ss", ffTime(start))
|
|
}
|
|
args = append(args, "-t", ffTime(cl.out-start), "-i", cl.m.Path)
|
|
if v := cl.m.Video; v != nil {
|
|
args = append(args, "-map", fmt.Sprintf("0:%d", v.Index))
|
|
}
|
|
if !req.NoAudio {
|
|
for i, s := range cl.m.Audio {
|
|
if i > 0 && !req.AllAudio {
|
|
break
|
|
}
|
|
args = append(args, "-map", fmt.Sprintf("0:%d", s.Index))
|
|
}
|
|
}
|
|
args = append(args, "-c", "copy", "-avoid_negative_ts", "make_zero", "-map_metadata", "0", "-map_chapters", "-1")
|
|
if exact {
|
|
args = append(args, "-copypriorss", "0")
|
|
}
|
|
return args
|
|
}
|
|
|
|
func copyOutputArgs(ext string, final bool) []string {
|
|
ext = strings.ToLower(ext)
|
|
if final && (ext == ".mp4" || ext == ".mov" || ext == ".m4v") {
|
|
return []string{"-movflags", "+faststart"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encodeArgs builds one ffmpeg command that decodes every clip (each as its
|
|
// own seeked input), normalizes size/frame rate/audio format and joins them
|
|
// with the concat filter.
|
|
func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, error) {
|
|
f, err := frameFormat(clips, req.Height, req.FPS)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
anyAudio := false
|
|
for _, cl := range clips {
|
|
if len(cl.m.Audio) > 0 {
|
|
anyAudio = true
|
|
}
|
|
}
|
|
anyAudio = anyAudio && !req.NoAudio
|
|
|
|
vc, _ := codecByName(req.VCodec)
|
|
var venc []string
|
|
venc, f.PixFmt = videoEncoderArgs(vc, req.Quality, req.Speed)
|
|
if anyAudio {
|
|
f.SampleRate, f.Layout = audioFormat(clips, req.ACodec)
|
|
}
|
|
|
|
inputs, graph, _ := joinGraph(clips, f)
|
|
args := append(ffmpegBaseArgs(), inputs...)
|
|
args = append(args, "-filter_complex", graph, "-map", "[outv]")
|
|
if anyAudio {
|
|
args = append(args, "-map", "[outa]")
|
|
}
|
|
args = append(args, venc...)
|
|
ext = strings.ToLower(ext)
|
|
if vc.Family == "hevc" && (ext == ".mp4" || ext == ".mov") {
|
|
args = append(args, "-tag:v", "hvc1") // plays on Apple devices too
|
|
}
|
|
if anyAudio {
|
|
args = append(args, "-c:a", req.ACodec)
|
|
if req.ACodec != "flac" {
|
|
args = append(args, "-b:a", fmt.Sprintf("%dk", req.ABitrate))
|
|
}
|
|
}
|
|
args = append(args, "-map_chapters", "-1")
|
|
if ext == ".mp4" || ext == ".mov" {
|
|
args = append(args, "-movflags", "+faststart")
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// gifArgs builds the two ffmpeg runs of a GIF export, each still missing its
|
|
// output path. The first picks the 256 colors that suit the whole animation
|
|
// best and writes them to palette; the second maps every frame onto them.
|
|
func gifArgs(clips []clipJob, req *ExportRequest, palette string) (paletteRun, gifRun []string, err error) {
|
|
f, err := frameFormat(clips, req.Height, req.FPS)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
f.PixFmt = "bgra" // what palettegen and paletteuse take
|
|
inputs, graph, n := joinGraph(clips, f)
|
|
|
|
paletteRun = append(ffmpegBaseArgs(), inputs...)
|
|
paletteRun = append(paletteRun, "-filter_complex", graph+";[outv]palettegen[pal]", "-map", "[pal]", "-frames:v", "1", "-update", "1")
|
|
|
|
// diff_mode=rectangle re-dithers only the part of a frame that changed,
|
|
// which keeps the dither noise still and the file smaller.
|
|
loop := "0" // forever
|
|
if !req.Loop {
|
|
loop = "-1"
|
|
}
|
|
gifRun = append(ffmpegBaseArgs(), inputs...)
|
|
gifRun = append(gifRun, "-i", palette,
|
|
"-filter_complex", fmt.Sprintf("%s;[outv][%d:v]paletteuse=dither=%s:diff_mode=rectangle[gif]", graph, n, req.Dither),
|
|
"-map", "[gif]", "-loop", loop)
|
|
return paletteRun, gifRun, nil
|
|
}
|
|
|
|
// outFormat is what joinGraph converts every clip to. SampleRate 0 means no
|
|
// audio.
|
|
type outFormat struct {
|
|
W, H int
|
|
FPS string
|
|
PixFmt string
|
|
SampleRate int
|
|
Layout string
|
|
}
|
|
|
|
// frameFormat picks the output frame size and rate: the first video clip's
|
|
// display size, scaled to height when set, and its frame rate unless fps is
|
|
// set.
|
|
func frameFormat(clips []clipJob, height int, fps string) (outFormat, error) {
|
|
var first *Media
|
|
for _, cl := range clips {
|
|
if cl.m.Video != nil {
|
|
first = cl.m
|
|
break
|
|
}
|
|
}
|
|
if first == nil {
|
|
return outFormat{}, fmt.Errorf("none of the clips contain video")
|
|
}
|
|
dw, dh := float64(first.Video.DisplayWidth), float64(first.Video.DisplayHeight)
|
|
if dw <= 0 || dh <= 0 {
|
|
dw, dh = 1280, 720
|
|
}
|
|
if height > 0 && float64(height) != dh {
|
|
dw, dh = dw*float64(height)/dh, float64(height)
|
|
}
|
|
if fps == "" {
|
|
fps = first.Video.FrameRate
|
|
}
|
|
return outFormat{W: even(dw), H: even(dh), FPS: fps}, nil
|
|
}
|
|
|
|
// joinGraph returns the input arguments (one seeked input per clip, plus
|
|
// generated black video or silence for clips that lack it), how many inputs
|
|
// that is, and a filter graph that converts every clip to f, letterboxing as
|
|
// needed, and joins them with the concat filter into [outv] (and [outa]).
|
|
func joinGraph(clips []clipJob, f outFormat) (args []string, graph string, inputs int) {
|
|
var filters []string
|
|
var concatIn strings.Builder
|
|
for i, cl := range clips {
|
|
// Start half a millisecond early so rounding never drops the In frame;
|
|
// the end (start + duration) then falls just before the Out frame.
|
|
d := ffTime(cl.dur())
|
|
args = append(args, "-ss", ffTime(math.Max(0, cl.in-0.0005)), "-t", d, "-i", cl.m.Path)
|
|
src := inputs
|
|
inputs++
|
|
|
|
if v := cl.m.Video; v != nil {
|
|
chain := fmt.Sprintf("[%d:%d]", src, v.Index)
|
|
if !v.SquarePixels {
|
|
chain += "scale='trunc(iw*sar/2)*2':ih,setsar=1,"
|
|
}
|
|
chain += fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=%s,format=%s[v%d]",
|
|
f.W, f.H, f.W, f.H, f.FPS, f.PixFmt, i)
|
|
filters = append(filters, chain)
|
|
} else {
|
|
args = append(args, "-f", "lavfi", "-t", d, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%s", f.W, f.H, f.FPS))
|
|
filters = append(filters, fmt.Sprintf("[%d:v]setsar=1,format=%s[v%d]", inputs, f.PixFmt, i))
|
|
inputs++
|
|
}
|
|
fmt.Fprintf(&concatIn, "[v%d]", i)
|
|
|
|
if f.SampleRate > 0 {
|
|
aformat := fmt.Sprintf("aresample=%d,aformat=sample_fmts=fltp:sample_rates=%d:channel_layouts=%s", f.SampleRate, f.SampleRate, f.Layout)
|
|
if s := cl.m.FirstAudio(); s != nil {
|
|
filters = append(filters, fmt.Sprintf("[%d:%d]%s[a%d]", src, s.Index, aformat, i))
|
|
} else {
|
|
args = append(args, "-f", "lavfi", "-t", d, "-i", fmt.Sprintf("anullsrc=r=%d:cl=%s", f.SampleRate, f.Layout))
|
|
filters = append(filters, fmt.Sprintf("[%d:a]%s[a%d]", inputs, aformat, i))
|
|
inputs++
|
|
}
|
|
fmt.Fprintf(&concatIn, "[a%d]", i)
|
|
}
|
|
}
|
|
concat := fmt.Sprintf("%sconcat=n=%d:v=1:a=0[outv]", concatIn.String(), len(clips))
|
|
if f.SampleRate > 0 {
|
|
concat = fmt.Sprintf("%sconcat=n=%d:v=1:a=1[outv][outa]", concatIn.String(), len(clips))
|
|
}
|
|
filters = append(filters, concat)
|
|
return args, strings.Join(filters, ";"), inputs
|
|
}
|
|
|
|
func even(f float64) int {
|
|
n := int(math.Round(f))
|
|
if n%2 != 0 {
|
|
n++
|
|
}
|
|
if n < 2 {
|
|
n = 2
|
|
}
|
|
return n
|
|
}
|
|
|
|
// audioFormat picks a common sample rate and channel layout for all clips.
|
|
func audioFormat(clips []clipJob, acodec string) (int, string) {
|
|
rate, layout := 0, ""
|
|
sameRate, sameLayout := true, true
|
|
for _, cl := range clips {
|
|
s := cl.m.FirstAudio()
|
|
if s == nil {
|
|
continue
|
|
}
|
|
l := s.ChannelLayout
|
|
if l == "" {
|
|
l = map[int]string{1: "mono", 2: "stereo"}[s.Channels]
|
|
}
|
|
if rate == 0 {
|
|
rate, layout = s.SampleRate, l
|
|
continue
|
|
}
|
|
sameRate = sameRate && s.SampleRate == rate
|
|
sameLayout = sameLayout && l == layout
|
|
}
|
|
if !sameRate || (rate != 44100 && rate != 48000) || acodec == "libopus" {
|
|
rate = 48000
|
|
}
|
|
surround := layout == "5.1" || layout == "5.1(side)" || layout == "7.1"
|
|
switch {
|
|
case sameLayout && layout == "mono":
|
|
case sameLayout && surround && (acodec == "aac" || acodec == "flac"):
|
|
default:
|
|
layout = "stereo"
|
|
}
|
|
return rate, layout
|
|
}
|
|
|
|
// videoEncoderArgs maps the generic quality (CRF-like, lower is better) and
|
|
// speed setting onto each encoder's own options.
|
|
func videoEncoderArgs(c Codec, q int, speed string) ([]string, string) {
|
|
pick := func(fast, balanced, slow string) string {
|
|
switch speed {
|
|
case "fast":
|
|
return fast
|
|
case "slow":
|
|
return slow
|
|
}
|
|
return balanced
|
|
}
|
|
qs := strconv.Itoa(q)
|
|
args := []string{"-c:v", c.Name}
|
|
pixFmt := "yuv420p"
|
|
switch {
|
|
case c.Name == "libx264" || c.Name == "libx265":
|
|
args = append(args, "-crf", qs, "-preset", pick("veryfast", "medium", "slow"))
|
|
if c.Name == "libx265" {
|
|
args = append(args, "-x265-params", "log-level=error")
|
|
}
|
|
case c.Name == "libsvtav1":
|
|
args = append(args, "-crf", qs, "-preset", pick("10", "7", "4"))
|
|
case c.Name == "libaom-av1":
|
|
args = append(args, "-crf", qs, "-b:v", "0", "-cpu-used", pick("8", "6", "4"), "-row-mt", "1")
|
|
case c.Name == "libvpx-vp9":
|
|
args = append(args, "-crf", qs, "-b:v", "0", "-deadline", "good", "-cpu-used", pick("5", "2", "1"), "-row-mt", "1")
|
|
case strings.HasSuffix(c.Name, "_nvenc"):
|
|
args = append(args, "-rc", "vbr", "-cq", qs, "-b:v", "0", "-preset", pick("p2", "p4", "p6"))
|
|
case strings.HasSuffix(c.Name, "_qsv"):
|
|
args = append(args, "-global_quality", qs, "-preset", pick("veryfast", "medium", "slow"))
|
|
pixFmt = "nv12"
|
|
case strings.HasSuffix(c.Name, "_amf"):
|
|
args = append(args, "-rc", "cqp", "-qp_i", qs, "-qp_p", qs, "-quality", pick("speed", "balanced", "quality"))
|
|
if c.Family == "h264" {
|
|
args = append(args, "-qp_b", qs)
|
|
}
|
|
}
|
|
return args, pixFmt
|
|
}
|