Linux versino and Gif output

This commit is contained in:
2026-10-17 06:21:19 +00:00
parent c6ac7d2222
commit 9fb9395f99
12 changed files with 728 additions and 122 deletions

266
export.go
View File

@@ -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 {