Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 02:28:47 +00:00
commit 396a676146
21 changed files with 5386 additions and 0 deletions

634
export.go Normal file
View 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
}