fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting
Build Windows Installer / build (push) Successful in 8m46s
Build Windows Installer / build (push) Successful in 8m46s
This commit is contained in:
12 files changed
+563
-226
No files matched your search
@@ -216,7 +216,7 @@ func BuildCommand(req Request, info *media.VideoInfo) (*BuiltCommand, error) {
|
||||
postArgs = append(postArgs, "-an")
|
||||
case "merge":
|
||||
tracks := req.SelectedTracks
|
||||
if len(tracks) == 0 {
|
||||
if tracks == nil {
|
||||
for i := 0; i < numAudio; i++ {
|
||||
tracks = append(tracks, i)
|
||||
}
|
||||
|
||||
@@ -198,3 +198,81 @@ func TestIntegration_RealFFmpegConvert(t *testing.T) {
|
||||
t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommand_EmptySelectedTracksMutes(t *testing.T) {
|
||||
info := &media.VideoInfo{
|
||||
Path: `C:\videos\clip.mp4`,
|
||||
Duration: 10,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
AudioStreams: []media.AudioStream{
|
||||
{Index: 1, AudioOrder: 0, Codec: "aac"},
|
||||
{Index: 2, AudioOrder: 1, Codec: "aac"},
|
||||
},
|
||||
}
|
||||
built, err := BuildCommand(Request{
|
||||
InputPath: `C:\videos\clip.mp4`,
|
||||
Encoder: "libx264",
|
||||
Preset: "ultrafast",
|
||||
CRF: 23,
|
||||
Speed: 1.0,
|
||||
AudioMode: "merge",
|
||||
SelectedTracks: []int{},
|
||||
}, info)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
joined := strings.Join(built.Args, " ")
|
||||
if !strings.Contains(joined, " -an ") {
|
||||
t.Fatalf("expected -an when SelectedTracks is explicit empty slice, got: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_NoClobberPreservesExistingOutput(t *testing.T) {
|
||||
bins := media.FindBinaries("", "")
|
||||
if bins.FFmpeg == "" {
|
||||
t.Skip("ffmpeg not installed on host")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join(tmpDir, "source.mp4")
|
||||
dst := filepath.Join(tmpDir, "existing.mp4")
|
||||
|
||||
gen := exec.Command(bins.FFmpeg,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x180:rate=30",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-y", src,
|
||||
)
|
||||
if out, err := gen.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed generating test video: %v (%s)", err, string(out))
|
||||
}
|
||||
|
||||
sentinel := []byte("keep-me-intact")
|
||||
if err := os.WriteFile(dst, sentinel, 0o644); err != nil {
|
||||
t.Fatalf("write sentinel failed: %v", err)
|
||||
}
|
||||
|
||||
info := &media.VideoInfo{Path: src, Duration: 1, Width: 320, Height: 180}
|
||||
built, err := BuildCommand(Request{
|
||||
InputPath: src,
|
||||
OutputPath: dst,
|
||||
Encoder: "libx264",
|
||||
Preset: "ultrafast",
|
||||
CRF: 24,
|
||||
Speed: 1.0,
|
||||
AudioMode: "mute",
|
||||
Overwrite: false,
|
||||
}, info)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommand failed: %v", err)
|
||||
}
|
||||
|
||||
runner := NewRunner()
|
||||
if err := runner.Run(context.Background(), bins.FFmpeg, built, nil); err == nil {
|
||||
t.Fatalf("expected error when Overwrite=false and destination exists")
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil || string(got) != string(sentinel) {
|
||||
t.Fatalf("existing destination file was modified or deleted! err=%v got=%q", err, string(got))
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,19 @@ func (r *Runner) Run(
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024}
|
||||
|
||||
_, statErr := os.Stat(built.OutputPath)
|
||||
existedBefore := statErr == nil
|
||||
hasNoClobber := false
|
||||
for _, a := range built.Args {
|
||||
if a == "-n" {
|
||||
hasNoClobber = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if existedBefore && hasNoClobber {
|
||||
return fmt.Errorf("output file already exists: %s", built.OutputPath)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||
}
|
||||
@@ -114,11 +127,15 @@ func (r *Runner) Run(
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
if ctx.Err() != nil {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
if !(existedBefore && hasNoClobber) {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
}
|
||||
return fmt.Errorf("conversion cancelled")
|
||||
}
|
||||
if waitErr != nil {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
if !(existedBefore && hasNoClobber) {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
}
|
||||
errTail := strings.TrimSpace(stderrBuf.String())
|
||||
if len(errTail) > 800 {
|
||||
errTail = errTail[len(errTail)-800:]
|
||||
|
||||
@@ -198,6 +198,11 @@ func parseRationalFPS(expr string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func IsH264BrowserNativePixFmt(pixFmt string) bool {
|
||||
p := strings.ToLower(strings.TrimSpace(pixFmt))
|
||||
return p == "" || p == "yuv420p" || p == "yuvj420p"
|
||||
}
|
||||
|
||||
func isBrowserNative(info *VideoInfo) bool {
|
||||
extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm"
|
||||
if !extOK {
|
||||
@@ -207,6 +212,9 @@ func isBrowserNative(info *VideoInfo) bool {
|
||||
if !vOK {
|
||||
return false
|
||||
}
|
||||
if info.VideoCodec == "h264" && !IsH264BrowserNativePixFmt(info.PixFmt) {
|
||||
return false
|
||||
}
|
||||
// If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine,
|
||||
// we still treat single-compatible audio as native.
|
||||
for _, a := range info.AudioStreams {
|
||||
|
||||
@@ -94,3 +94,36 @@ func TestParseRationalFPS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProbeJSON_H264PixFmtCompatibility(t *testing.T) {
|
||||
makeSample := func(pixFmt string) []byte {
|
||||
return []byte(`{
|
||||
"format": {"duration": "10.0"},
|
||||
"streams": [{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1280, "height": 720, "pix_fmt": "` + pixFmt + `"}]
|
||||
}`)
|
||||
}
|
||||
|
||||
okInfo, err := ParseProbeJSON(makeSample("yuv420p"), "clip.mp4", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !okInfo.BrowserNative {
|
||||
t.Fatalf("expected yuv420p mp4 to be browser native")
|
||||
}
|
||||
|
||||
hi10Info, err := ParseProbeJSON(makeSample("yuv420p10le"), "clip10.mp4", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hi10Info.BrowserNative {
|
||||
t.Fatalf("expected 10-bit yuv420p10le mp4 NOT to be browser native")
|
||||
}
|
||||
|
||||
fmt422Info, err := ParseProbeJSON(makeSample("yuv422p"), "clip422.mp4", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fmt422Info.BrowserNative {
|
||||
t.Fatalf("expected yuv422p mp4 NOT to be browser native")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
type Server struct {
|
||||
addr string
|
||||
httpSrv *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
ffmpegBin string
|
||||
mu sync.RWMutex
|
||||
files map[string]registeredFile
|
||||
@@ -41,8 +43,11 @@ func Start(ffmpegBin string) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{
|
||||
addr: ln.Addr().String(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ffmpegBin: ffmpegBin,
|
||||
files: make(map[string]registeredFile),
|
||||
proxyLocks: make(map[string]*sync.Mutex),
|
||||
@@ -78,7 +83,12 @@ func (s *Server) BaseURL() string {
|
||||
}
|
||||
|
||||
func (s *Server) Register(info *media.VideoInfo) string {
|
||||
sum := sha1.Sum([]byte(info.Path))
|
||||
var modUnix int64
|
||||
if st, err := os.Stat(info.Path); err == nil {
|
||||
modUnix = st.ModTime().UnixNano()
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%d", info.Path, info.SizeBytes, modUnix)
|
||||
sum := sha1.Sum([]byte(key))
|
||||
id := hex.EncodeToString(sum[:8])
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -88,6 +98,9 @@ func (s *Server) Register(info *media.VideoInfo) string {
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.httpSrv != nil {
|
||||
@@ -166,7 +179,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 {
|
||||
if err := s.buildProxy(s.ctx, ffmpegBin, rf.info, merge, proxyPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -211,8 +224,8 @@ func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.V
|
||||
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
|
||||
if info.VideoCodec == "h264" && media.IsH264BrowserNativePixFmt(info.PixFmt) {
|
||||
// Remux video stream instantaneously when codec is already browser-native 8-bit H.264
|
||||
args = append(args, "-c:v", "copy")
|
||||
} else {
|
||||
args = append(args,
|
||||
@@ -220,7 +233,7 @@ func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.V
|
||||
"-preset", "ultrafast",
|
||||
"-crf", "26",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
"-vf", "scale='min(1280,trunc(iw/2)*2)':-2",
|
||||
)
|
||||
}
|
||||
args = append(args, "-movflags", "+faststart", outPath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package preview
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -85,3 +86,57 @@ func TestPreviewServer_RangeThumbAndProxy(t *testing.T) {
|
||||
t.Fatalf("expected 404, got %d", nfResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewServer_CacheInvalidationAndOddWidthProxy(t *testing.T) {
|
||||
bins := media.FindBinaries("", "")
|
||||
if bins.FFmpeg == "" || bins.FFprobe == "" {
|
||||
t.Skip("ffmpeg/ffprobe not installed")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join(tmpDir, "odd.mkv")
|
||||
// Odd width (321x180) vp8 source forces libx264 transcode in /proxy
|
||||
gen := exec.Command(bins.FFmpeg,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=1:size=321x180:rate=30",
|
||||
"-c:v", "libvpx", "-y", src,
|
||||
)
|
||||
if out, err := gen.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed generating odd-width 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("start failed: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
id1 := srv.Register(info)
|
||||
|
||||
// /proxy must succeed even on odd width (321 -> 320)
|
||||
pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id1 + "&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 for odd-width source, status=%d len=%d", pResp.StatusCode, len(pBody))
|
||||
}
|
||||
|
||||
// Overwrite same path with different size -> Register must yield a new ID
|
||||
if err := os.WriteFile(src, []byte("different-content-size"), 0o644); err != nil {
|
||||
t.Fatalf("overwrite failed: %v", err)
|
||||
}
|
||||
info2 := *info
|
||||
info2.SizeBytes = 22
|
||||
id2 := srv.Register(&info2)
|
||||
if id1 == id2 {
|
||||
t.Fatalf("expected different videoId after file modification at same path, both were %q", id1)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user