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 }