634
export.go
Normal file
634
export.go
Normal file
@@ -0,0 +1,634 @@
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 (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.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) {
|
||||
var first *Media
|
||||
anyAudio := false
|
||||
for _, cl := range clips {
|
||||
if first == nil && cl.m.Video != nil {
|
||||
first = cl.m
|
||||
}
|
||||
if len(cl.m.Audio) > 0 {
|
||||
anyAudio = true
|
||||
}
|
||||
}
|
||||
if first == nil {
|
||||
return nil, fmt.Errorf("none of the clips contain video")
|
||||
}
|
||||
anyAudio = anyAudio && !req.NoAudio
|
||||
|
||||
// Output frame size: the first clip's display size, optionally scaled.
|
||||
dw, dh := float64(first.Video.DisplayWidth), float64(first.Video.DisplayHeight)
|
||||
if dw <= 0 || dh <= 0 {
|
||||
dw, dh = 1280, 720
|
||||
}
|
||||
if req.Height > 0 && float64(req.Height) != dh {
|
||||
dw, dh = dw*float64(req.Height)/dh, float64(req.Height)
|
||||
}
|
||||
W, H := even(dw), even(dh)
|
||||
fps := req.FPS
|
||||
if fps == "" {
|
||||
fps = first.Video.FrameRate
|
||||
}
|
||||
|
||||
vc, _ := codecByName(req.VCodec)
|
||||
venc, pixFmt := videoEncoderArgs(vc, req.Quality, req.Speed)
|
||||
|
||||
sampleRate, layout := 48000, "stereo"
|
||||
if anyAudio {
|
||||
sampleRate, layout = audioFormat(clips, req.ACodec)
|
||||
}
|
||||
|
||||
args := ffmpegBaseArgs()
|
||||
var filters []string
|
||||
var concatIn strings.Builder
|
||||
input := 0
|
||||
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 := input
|
||||
input++
|
||||
|
||||
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]",
|
||||
W, H, W, H, fps, 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", W, H, fps))
|
||||
filters = append(filters, fmt.Sprintf("[%d:v]setsar=1,format=%s[v%d]", input, pixFmt, i))
|
||||
input++
|
||||
}
|
||||
fmt.Fprintf(&concatIn, "[v%d]", i)
|
||||
|
||||
if anyAudio {
|
||||
aformat := fmt.Sprintf("aresample=%d,aformat=sample_fmts=fltp:sample_rates=%d:channel_layouts=%s", sampleRate, sampleRate, 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", sampleRate, layout))
|
||||
filters = append(filters, fmt.Sprintf("[%d:a]%s[a%d]", input, aformat, i))
|
||||
input++
|
||||
}
|
||||
fmt.Fprintf(&concatIn, "[a%d]", i)
|
||||
}
|
||||
}
|
||||
concat := fmt.Sprintf("%sconcat=n=%d:v=1:a=0[outv]", concatIn.String(), len(clips))
|
||||
if anyAudio {
|
||||
concat = fmt.Sprintf("%sconcat=n=%d:v=1:a=1[outv][outa]", concatIn.String(), len(clips))
|
||||
}
|
||||
filters = append(filters, concat)
|
||||
args = append(args, "-filter_complex", strings.Join(filters, ";"), "-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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user