Linux versino and Gif output
This commit is contained in:
266
export.go
266
export.go
@@ -23,7 +23,7 @@ type ExportClip struct {
|
||||
|
||||
type ExportRequest struct {
|
||||
Clips []ExportClip `json:"clips"`
|
||||
Mode string `json:"mode"` // copy | encode
|
||||
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"`
|
||||
@@ -38,8 +38,14 @@ type ExportRequest struct {
|
||||
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
|
||||
@@ -94,6 +100,11 @@ func (a *App) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -253,6 +264,25 @@ func (a *App) validateEncode(req *ExportRequest, ext string) error {
|
||||
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 {
|
||||
@@ -348,6 +378,46 @@ func (a *App) runExport(ctx context.Context, c *JobCtl, req *ExportRequest, clip
|
||||
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]))
|
||||
@@ -445,88 +515,28 @@ func copyOutputArgs(ext string, final bool) []string {
|
||||
// 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
|
||||
f, err := frameFormat(clips, req.Height, req.FPS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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"
|
||||
var venc []string
|
||||
venc, f.PixFmt = videoEncoderArgs(vc, req.Quality, req.Speed)
|
||||
if anyAudio {
|
||||
sampleRate, layout = audioFormat(clips, req.ACodec)
|
||||
f.SampleRate, f.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]")
|
||||
inputs, graph, _ := joinGraph(clips, f)
|
||||
args := append(ffmpegBaseArgs(), inputs...)
|
||||
args = append(args, "-filter_complex", graph, "-map", "[outv]")
|
||||
if anyAudio {
|
||||
args = append(args, "-map", "[outa]")
|
||||
}
|
||||
@@ -548,6 +558,120 @@ func encodeArgs(clips []clipJob, req *ExportRequest, ext string) ([]string, erro
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user