feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s
Build Windows Installer / build (push) Successful in 4m11s
This commit is contained in:
40 files changed
+6084
-266
No files matched your search
@@ -0,0 +1,300 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"convert/internal/media"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
httpSrv *http.Server
|
||||
ffmpegBin string
|
||||
mu sync.RWMutex
|
||||
files map[string]registeredFile
|
||||
proxyLocks map[string]*sync.Mutex
|
||||
proxyCache map[string]string
|
||||
thumbCache map[string][]byte
|
||||
tempFiles []string
|
||||
}
|
||||
|
||||
type registeredFile struct {
|
||||
info *media.VideoInfo
|
||||
}
|
||||
|
||||
func Start(ffmpegBin string) (*Server, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
addr: ln.Addr().String(),
|
||||
ffmpegBin: ffmpegBin,
|
||||
files: make(map[string]registeredFile),
|
||||
proxyLocks: make(map[string]*sync.Mutex),
|
||||
proxyCache: make(map[string]string),
|
||||
thumbCache: make(map[string][]byte),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video", s.handleVideo)
|
||||
mux.HandleFunc("/proxy", s.handleProxy)
|
||||
mux.HandleFunc("/thumb", s.handleThumb)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = s.httpSrv.Serve(ln)
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) SetFFmpeg(bin string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ffmpegBin = bin
|
||||
}
|
||||
|
||||
func (s *Server) BaseURL() string {
|
||||
return "http://" + s.addr
|
||||
}
|
||||
|
||||
func (s *Server) Register(info *media.VideoInfo) string {
|
||||
sum := sha1.Sum([]byte(info.Path))
|
||||
id := hex.EncodeToString(sum[:8])
|
||||
|
||||
s.mu.Lock()
|
||||
s.files[id] = registeredFile{info: info}
|
||||
s.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.httpSrv != nil {
|
||||
_ = s.httpSrv.Shutdown(ctx)
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, p := range s.tempFiles {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
s.tempFiles = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) getFile(id string) (registeredFile, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rf, ok := s.files[id]
|
||||
return rf, ok
|
||||
}
|
||||
|
||||
func (s *Server) handleVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(rf.info.Path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
http.ServeContent(w, r, rf.info.FileName, st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
merge := r.URL.Query().Get("merge") == "1" || r.URL.Query().Get("merge") == "true"
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ffmpegBin := s.ffmpegBin
|
||||
s.mu.RUnlock()
|
||||
if ffmpegBin == "" {
|
||||
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_m%v", id, merge)
|
||||
s.mu.Lock()
|
||||
lk, exists := s.proxyLocks[cacheKey]
|
||||
if !exists {
|
||||
lk = &sync.Mutex{}
|
||||
s.proxyLocks[cacheKey] = lk
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
s.mu.RLock()
|
||||
proxyPath := s.proxyCache[cacheKey]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if proxyPath == "" {
|
||||
proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey))
|
||||
if err := s.buildProxy(r.Context(), ffmpegBin, rf.info, merge, proxyPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.proxyCache[cacheKey] = proxyPath
|
||||
s.tempFiles = append(s.tempFiles, proxyPath)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
f, err := os.Open(proxyPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
http.ServeContent(w, r, "preview.mp4", st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.VideoInfo, merge bool, outPath string) error {
|
||||
args := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", info.Path}
|
||||
|
||||
numAudio := len(info.AudioStreams)
|
||||
if numAudio == 0 {
|
||||
args = append(args, "-map", "0:v:0", "-an")
|
||||
} else if merge && numAudio > 1 {
|
||||
var in strings.Builder
|
||||
for i := 0; i < numAudio; i++ {
|
||||
in.WriteString(fmt.Sprintf("[0:a:%d]", i))
|
||||
}
|
||||
args = append(args,
|
||||
"-filter_complex", fmt.Sprintf("%samix=inputs=%d:normalize=0[a]", in.String(), numAudio),
|
||||
"-map", "0:v:0", "-map", "[a]",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
)
|
||||
} else {
|
||||
args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k")
|
||||
}
|
||||
|
||||
if info.VideoCodec == "h264" {
|
||||
// Remux video stream instantaneously when codec is already H.264
|
||||
args = append(args, "-c:v", "copy")
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-crf", "26",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
)
|
||||
}
|
||||
args = append(args, "-movflags", "+faststart", outPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
|
||||
media.HideCmdWindow(cmd)
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("preview proxy error: %v (%s)", err, strings.TrimSpace(errBuf.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
tStr := r.URL.Query().Get("t")
|
||||
rf, ok := s.getFile(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tSec, err := strconv.ParseFloat(tStr, 64)
|
||||
if err != nil || tSec < 0 {
|
||||
tSec = 0
|
||||
}
|
||||
if rf.info.Duration > 0 && tSec >= rf.info.Duration {
|
||||
tSec = rf.info.Duration - 0.1
|
||||
if tSec < 0 {
|
||||
tSec = 0
|
||||
}
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s_%.2f", id, tSec)
|
||||
s.mu.RLock()
|
||||
cached, found := s.thumbCache[key]
|
||||
ffmpegBin := s.ffmpegBin
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
if ffmpegBin == "" {
|
||||
http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
cmd := exec.CommandContext(r.Context(), ffmpegBin,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-ss", strconv.FormatFloat(tSec, 'f', 3, 64),
|
||||
"-i", rf.info.Path,
|
||||
"-frames:v", "1",
|
||||
"-vf", "scale=200:-1",
|
||||
"-q:v", "6",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"pipe:1",
|
||||
)
|
||||
media.HideCmdWindow(cmd)
|
||||
data, err := cmd.Output()
|
||||
if err != nil || len(data) == 0 {
|
||||
http.Error(w, "thumbnail generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if len(s.thumbCache) > 200 {
|
||||
// simple cap reset
|
||||
s.thumbCache = make(map[string][]byte)
|
||||
}
|
||||
s.thumbCache[key] = data
|
||||
s.mu.Unlock()
|
||||
cached = data
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_, _ = w.Write(cached)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"convert/internal/media"
|
||||
)
|
||||
|
||||
func TestPreviewServer_RangeThumbAndProxy(t *testing.T) {
|
||||
bins := media.FindBinaries("", "")
|
||||
if bins.FFmpeg == "" || bins.FFprobe == "" {
|
||||
t.Skip("ffmpeg/ffprobe not installed")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join(tmpDir, "clip.mkv")
|
||||
gen := exec.Command(bins.FFmpeg,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=2:size=320x180:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src,
|
||||
)
|
||||
if out, err := gen.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed generating sample mkv: %v (%s)", err, string(out))
|
||||
}
|
||||
|
||||
info, err := media.Probe(bins.FFprobe, src)
|
||||
if err != nil {
|
||||
t.Fatalf("probe failed: %v", err)
|
||||
}
|
||||
|
||||
srv, err := Start(bins.FFmpeg)
|
||||
if err != nil {
|
||||
t.Fatalf("preview server start failed: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
id := srv.Register(info)
|
||||
|
||||
// 1. Test Range request on /video
|
||||
req, _ := http.NewRequest("GET", srv.BaseURL()+"/video?id="+id, nil)
|
||||
req.Header.Set("Range", "bytes=0-127")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /video failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("expected 206 Partial Content, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 2. Test /thumb JPEG generation
|
||||
tResp, err := http.Get(srv.BaseURL() + "/thumb?id=" + id + "&t=0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /thumb failed: %v", err)
|
||||
}
|
||||
tBody, _ := io.ReadAll(tResp.Body)
|
||||
tResp.Body.Close()
|
||||
if tResp.StatusCode != http.StatusOK || len(tBody) < 100 || tBody[0] != 0xFF || tBody[1] != 0xD8 {
|
||||
t.Fatalf("expected valid JPEG thumbnail, status=%d len=%d", tResp.StatusCode, len(tBody))
|
||||
}
|
||||
|
||||
// 3. Test /proxy MKV -> MP4 remux
|
||||
pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id + "&merge=0")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /proxy failed: %v", err)
|
||||
}
|
||||
pBody, _ := io.ReadAll(pResp.Body)
|
||||
pResp.Body.Close()
|
||||
if pResp.StatusCode != http.StatusOK || len(pBody) < 500 {
|
||||
t.Fatalf("expected valid MP4 proxy, status=%d len=%d", pResp.StatusCode, len(pBody))
|
||||
}
|
||||
|
||||
// 4. Test unknown ID returns 404
|
||||
nfResp, err := http.Get(srv.BaseURL() + "/video?id=nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /video 404 failed: %v", err)
|
||||
}
|
||||
nfResp.Body.Close()
|
||||
if nfResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", nfResp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user