feat: ConvertGUI v2.0 (Wails + preview/trim + NSIS + Gitea CI)
Build Windows Installer / build (push) Successful in 4m11s

This commit is contained in:
m4xx1m committed 2026-11-12 17:30:44 +03:00
1 parent 02fd1dc817
commit 65387c5dcd
40 files changed
+6084 -266

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
)
type AppConfig struct {
Language string `json:"language"` // "ru" or "en"
FFmpegPath string `json:"ffmpegPath"`
FFprobePath string `json:"ffprobePath"`
DefaultEncoder string `json:"defaultEncoder"` // libx264, h264_nvenc, libx265, hevc_nvenc
DefaultHwDecode string `json:"defaultHwDecode"`
DefaultPreset string `json:"defaultPreset"`
DefaultCRF int `json:"defaultCrf"`
DefaultFPS int `json:"defaultFps"` // 0 = original, 60 = 60fps
DefaultTune string `json:"defaultTune"`
DefaultMergeAudio bool `json:"defaultMergeAudio"`
OutputSuffix string `json:"outputSuffix"`
}
func Default() AppConfig {
return AppConfig{
Language: "ru",
DefaultEncoder: "libx264",
DefaultHwDecode: "none",
DefaultPreset: "slow",
DefaultCRF: 20,
DefaultFPS: 60,
DefaultTune: "film",
DefaultMergeAudio: true,
OutputSuffix: "_converted",
}
}
func ConfigPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ConvertGUI", "config.json"), nil
}
func Load() AppConfig {
cfg := Default()
path, err := ConfigPath()
if err != nil {
return cfg
}
data, err := os.ReadFile(path)
if err != nil {
return cfg
}
_ = json.Unmarshal(data, &cfg)
if cfg.Language == "" {
cfg.Language = "ru"
}
if cfg.DefaultEncoder == "" {
cfg.DefaultEncoder = "libx264"
}
if cfg.DefaultPreset == "" {
cfg.DefaultPreset = "slow"
}
if cfg.DefaultCRF < 0 || cfg.DefaultCRF > 51 {
cfg.DefaultCRF = 20
}
return cfg
}
func Save(cfg AppConfig) error {
path, err := ConfigPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaultsAndCRFClamp(t *testing.T) {
tmp := t.TempDir()
t.Setenv("APPDATA", tmp)
t.Setenv("XDG_CONFIG_HOME", tmp)
cfg := Default()
if cfg.DefaultCRF != 20 || cfg.DefaultPreset != "slow" || cfg.Language != "ru" {
t.Fatalf("unexpected defaults: %+v", cfg)
}
cfg.DefaultCRF = 99
if err := Save(cfg); err != nil {
t.Fatalf("save failed: %v", err)
}
loaded := Load()
if loaded.DefaultCRF != 20 {
t.Fatalf("expected invalid CRF 99 to reset to 20, got %d", loaded.DefaultCRF)
}
// CRF 0 (lossless) must be preserved
cfg.DefaultCRF = 0
if err := Save(cfg); err != nil {
t.Fatalf("save CRF 0 failed: %v", err)
}
loadedZero := Load()
if loadedZero.DefaultCRF != 0 {
t.Fatalf("expected CRF 0 to be preserved, got %d", loadedZero.DefaultCRF)
}
path, _ := ConfigPath()
if _, err := os.Stat(filepath.Clean(path)); err != nil {
t.Fatalf("config file missing: %v", err)
}
}
+367
View File
@@ -0,0 +1,367 @@
package convert
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"convert/internal/media"
)
type CropRect struct {
Enabled bool `json:"enabled"`
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
type Request struct {
InputPath string `json:"inputPath"`
OutputPath string `json:"outputPath"`
TrimStart float64 `json:"trimStart"`
TrimEnd float64 `json:"trimEnd"`
Encoder string `json:"encoder"` // libx264, h264_nvenc, libx265, hevc_nvenc
HwDecode string `json:"hwDecode"` // none, nvdec, d3d11va, auto
Preset string `json:"preset"`
CRF int `json:"crf"`
Tune string `json:"tune"` // film, animation, grain, none
FPS int `json:"fps"` // 0 = keep original
Speed float64 `json:"speed"`
AudioMode string `json:"audioMode"` // merge, track, mute
AudioTrack int `json:"audioTrack"`
SelectedTracks []int `json:"selectedTracks"`
Crop CropRect `json:"crop"`
FastStart bool `json:"fastStart"`
Overwrite bool `json:"overwrite"`
}
type BuiltCommand struct {
Args []string `json:"args"`
OutputPath string `json:"outputPath"`
ExpectedDuration float64 `json:"expectedDuration"`
}
func DefaultOutputPath(inputPath, suffix string) string {
if suffix == "" {
suffix = "_converted"
}
dir := filepath.Dir(inputPath)
base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
candidate := filepath.Join(dir, base+suffix+".mp4")
if strings.EqualFold(filepath.Clean(candidate), filepath.Clean(inputPath)) {
candidate = filepath.Join(dir, base+"_out.mp4")
}
return candidate
}
func formatCleanFloat(v float64) string {
rounded := math.Round(v*10000) / 10000
return strconv.FormatFloat(rounded, 'f', -1, 64)
}
func BuildAtempoChain(speed float64) []string {
if speed <= 0 || math.Abs(speed-1.0) < 0.001 {
return nil
}
var filters []string
rem := speed
for rem > 2.0+1e-6 {
filters = append(filters, "atempo=2")
rem /= 2.0
}
for rem < 0.5-1e-6 {
filters = append(filters, "atempo=0.5")
rem /= 0.5
}
if math.Abs(rem-1.0) >= 0.0001 {
filters = append(filters, "atempo="+formatCleanFloat(rem))
}
return filters
}
func BuildCommand(req Request, info *media.VideoInfo) (*BuiltCommand, error) {
if req.InputPath == "" {
return nil, fmt.Errorf("input path is required")
}
if info == nil {
return nil, fmt.Errorf("video info is required")
}
if req.TrimStart < 0 {
req.TrimStart = 0
}
if req.TrimEnd > 0 && req.TrimEnd <= req.TrimStart {
return nil, fmt.Errorf("trim end (%.2fs) must be greater than trim start (%.2fs)", req.TrimEnd, req.TrimStart)
}
if req.Speed <= 0 {
req.Speed = 1.0
}
if req.Speed < 0.25 || req.Speed > 8.0 {
return nil, fmt.Errorf("speed must be between 0.25x and 8.0x")
}
if req.CRF < 0 || req.CRF > 51 {
return nil, fmt.Errorf("CRF must be between 0 and 51")
}
if req.Encoder == "" {
req.Encoder = "libx264"
}
if req.Preset == "" {
req.Preset = "slow"
}
outPath := strings.TrimSpace(req.OutputPath)
if outPath == "" {
outPath = DefaultOutputPath(req.InputPath, "_converted")
}
if strings.EqualFold(filepath.Clean(outPath), filepath.Clean(req.InputPath)) {
return nil, fmt.Errorf("output path cannot be the same as input path")
}
args := []string{"-hide_banner"}
if req.Overwrite {
args = append(args, "-y")
} else {
args = append(args, "-n")
}
// Input hardware decode args
switch strings.ToLower(req.HwDecode) {
case "nvdec":
args = append(args, "-hwaccel", "nvdec", "-threads", "1")
case "d3d11va":
args = append(args, "-hwaccel", "d3d11va")
case "auto":
args = append(args, "-hwaccel", "auto")
}
// Trim before -i for fast seeking
if req.TrimStart > 0.001 {
args = append(args, "-ss", formatSeconds(req.TrimStart))
}
effectiveEnd := info.Duration
if req.TrimEnd > 0.001 && (info.Duration <= 0 || req.TrimEnd < info.Duration-0.01) {
args = append(args, "-to", formatSeconds(req.TrimEnd))
effectiveEnd = req.TrimEnd
}
if effectiveEnd <= req.TrimStart {
effectiveEnd = info.Duration
}
sourceClipDur := effectiveEnd - req.TrimStart
if sourceClipDur < 0 {
sourceClipDur = 0
}
expectedDur := sourceClipDur / req.Speed
args = append(args, "-i", req.InputPath)
// Video filters
var videoFilters []string
if req.Crop.Enabled && req.Crop.W > 0 && req.Crop.H > 0 {
cx := max(0, req.Crop.X)
cy := max(0, req.Crop.Y)
cw := req.Crop.W &^ 1
ch := req.Crop.H &^ 1
if cw < 2 {
cw = 2
}
if ch < 2 {
ch = 2
}
if info.Width > 0 && cx+cw > info.Width {
cw = (info.Width - cx) &^ 1
}
if info.Height > 0 && cy+ch > info.Height {
ch = (info.Height - cy) &^ 1
}
if cw >= 2 && ch >= 2 {
videoFilters = append(videoFilters, fmt.Sprintf("crop=%d:%d:%d:%d", cw, ch, cx, cy))
}
}
if math.Abs(req.Speed-1.0) >= 0.001 {
videoFilters = append(videoFilters, fmt.Sprintf("setpts=1/%s*PTS", formatCleanFloat(req.Speed)))
}
// Audio setup
numAudio := len(info.AudioStreams)
audioMode := strings.ToLower(req.AudioMode)
if audioMode == "" {
if numAudio > 1 {
audioMode = "merge"
} else {
audioMode = "track"
}
}
if numAudio == 0 {
audioMode = "mute"
}
var filterComplexParts []string
var postArgs []string
if len(videoFilters) > 0 {
filterComplexParts = append(filterComplexParts, "[0:v:0]"+strings.Join(videoFilters, ",")+"[v]")
postArgs = append(postArgs, "-map", "[v]")
} else {
postArgs = append(postArgs, "-map", "0:v:0")
}
atempoFilters := BuildAtempoChain(req.Speed)
switch audioMode {
case "mute":
postArgs = append(postArgs, "-an")
case "merge":
tracks := req.SelectedTracks
if len(tracks) == 0 {
for i := 0; i < numAudio; i++ {
tracks = append(tracks, i)
}
}
// Filter valid track indices
var validTracks []int
seen := map[int]bool{}
for _, idx := range tracks {
if idx >= 0 && idx < numAudio && !seen[idx] {
seen[idx] = true
validTracks = append(validTracks, idx)
}
}
if len(validTracks) == 0 {
postArgs = append(postArgs, "-an")
} else if len(validTracks) == 1 {
tIdx := validTracks[0]
if len(atempoFilters) > 0 {
filterComplexParts = append(filterComplexParts, fmt.Sprintf("[0:a:%d]%s[a]", tIdx, strings.Join(atempoFilters, ",")))
postArgs = append(postArgs, "-map", "[a]")
} else {
postArgs = append(postArgs, "-map", fmt.Sprintf("0:a:%d", tIdx))
}
} else {
var inputs strings.Builder
for _, tIdx := range validTracks {
inputs.WriteString(fmt.Sprintf("[0:a:%d]", tIdx))
}
chain := fmt.Sprintf("amix=inputs=%d:normalize=0", len(validTracks))
if len(atempoFilters) > 0 {
chain += "," + strings.Join(atempoFilters, ",")
}
filterComplexParts = append(filterComplexParts, inputs.String()+chain+"[a]")
postArgs = append(postArgs, "-map", "[a]")
}
default: // "track"
tIdx := req.AudioTrack
if tIdx < 0 || tIdx >= numAudio {
tIdx = 0
}
if len(atempoFilters) > 0 {
filterComplexParts = append(filterComplexParts, fmt.Sprintf("[0:a:%d]%s[a]", tIdx, strings.Join(atempoFilters, ",")))
postArgs = append(postArgs, "-map", "[a]")
} else {
postArgs = append(postArgs, "-map", fmt.Sprintf("0:a:%d", tIdx))
}
}
if len(filterComplexParts) > 0 {
args = append(args, "-filter_complex", strings.Join(filterComplexParts, ";"))
}
args = append(args, postArgs...)
// Video codec args
switch req.Encoder {
case "h264_nvenc":
args = append(args,
"-c:v", "h264_nvenc",
"-profile:v", "high",
"-pix_fmt", "yuv420p",
"-preset", mapNvencPreset(req.Preset),
"-rc", "vbr",
"-cq", strconv.Itoa(req.CRF),
"-b:v", "0",
)
case "libx265":
args = append(args,
"-c:v", "libx265",
"-pix_fmt", "yuv420p",
"-preset", req.Preset,
"-crf", strconv.Itoa(req.CRF),
"-tag:v", "hvc1",
)
case "hevc_nvenc":
args = append(args,
"-c:v", "hevc_nvenc",
"-pix_fmt", "yuv420p",
"-preset", mapNvencPreset(req.Preset),
"-rc", "vbr",
"-cq", strconv.Itoa(req.CRF),
"-b:v", "0",
"-tag:v", "hvc1",
)
default: // libx264
args = append(args,
"-c:v", "libx264",
"-profile:v", "high",
"-pix_fmt", "yuv420p",
"-preset", req.Preset,
"-crf", strconv.Itoa(req.CRF),
)
if req.Tune != "" && req.Tune != "none" {
args = append(args, "-tune", req.Tune)
}
}
if audioMode != "mute" {
args = append(args, "-c:a", "aac", "-b:a", "192k")
}
if req.FPS > 0 {
args = append(args, "-r", strconv.Itoa(req.FPS))
}
if req.FastStart {
args = append(args, "-movflags", "+faststart")
}
args = append(args, outPath)
return &BuiltCommand{
Args: args,
OutputPath: outPath,
ExpectedDuration: expectedDur,
}, nil
}
func mapNvencPreset(p string) string {
switch strings.ToLower(p) {
case "ultrafast", "superfast":
return "p1"
case "veryfast":
return "p2"
case "faster":
return "p3"
case "fast":
return "p4"
case "medium":
return "p5"
case "slow":
return "p6"
case "slower", "veryslow":
return "p7"
case "p1", "p2", "p3", "p4", "p5", "p6", "p7":
return p
default:
return "p6"
}
}
func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
}
func EnsureUniqueOutputDir(outPath string) error {
dir := filepath.Dir(outPath)
return os.MkdirAll(dir, 0o755)
}
+200
View File
@@ -0,0 +1,200 @@
package convert
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"convert/internal/media"
)
func TestBuildAtempoChain(t *testing.T) {
if got := BuildAtempoChain(1.0); len(got) != 0 {
t.Fatalf("expected empty chain for 1.0, got %v", got)
}
got4 := BuildAtempoChain(4.0)
if len(got4) != 2 || got4[0] != "atempo=2" || got4[1] != "atempo=2" {
t.Fatalf("unexpected 4x chain: %v", got4)
}
gotQuarter := BuildAtempoChain(0.25)
if len(gotQuarter) != 2 || gotQuarter[0] != "atempo=0.5" || gotQuarter[1] != "atempo=0.5" {
t.Fatalf("unexpected 0.25x chain: %v", gotQuarter)
}
}
func TestBuildCommand_MultiAudioMergeAndTrim(t *testing.T) {
info := &media.VideoInfo{
Path: `C:\videos\clip.mp4`,
Duration: 60,
Width: 1920,
Height: 1080,
AudioStreams: []media.AudioStream{
{Index: 1, AudioOrder: 0, Codec: "aac"},
{Index: 2, AudioOrder: 1, Codec: "aac"},
},
}
req := Request{
InputPath: `C:\videos\clip.mp4`,
TrimStart: 10,
TrimEnd: 30,
Encoder: "libx264",
HwDecode: "nvdec",
Preset: "slow",
CRF: 20,
Tune: "film",
FPS: 60,
Speed: 2.0,
AudioMode: "merge",
Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 1281, H: 721},
FastStart: true,
Overwrite: true,
}
built, err := BuildCommand(req, info)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if built.ExpectedDuration != 10 {
t.Fatalf("expected 10s output duration, got %v", built.ExpectedDuration)
}
joined := strings.Join(built.Args, " ")
for _, must := range []string{
"-hwaccel nvdec",
"-ss 10.000",
"-to 30.000",
"crop=1280:720:10:10",
"setpts=1/2*PTS",
"[0:a:0][0:a:1]amix=inputs=2:normalize=0,atempo=2[a]",
"-c:v libx264",
"-crf 20",
"-preset slow",
"-tune film",
"-r 60",
"-movflags +faststart",
} {
if !strings.Contains(joined, must) {
t.Fatalf("expected %q in args: %s", must, joined)
}
}
}
func TestBuildCommand_ValidationErrors(t *testing.T) {
info := &media.VideoInfo{Path: "a.mp4", Duration: 30}
if _, err := BuildCommand(Request{InputPath: "a.mp4", TrimStart: 20, TrimEnd: 10, Speed: 1}, info); err == nil {
t.Fatalf("expected error when trimEnd <= trimStart")
}
if _, err := BuildCommand(Request{InputPath: "a.mp4", OutputPath: "a.mp4", Speed: 1}, info); err == nil {
t.Fatalf("expected error when output == input")
}
if _, err := BuildCommand(Request{InputPath: "a.mp4", Speed: 12}, info); err == nil {
t.Fatalf("expected error for speed > 8.0")
}
}
func TestProgressParser(t *testing.T) {
p := newProgressParser(10.0)
lines := []string{
"fps=120.5",
"total_size=500000",
"out_time_us=5000000",
"speed=2.0x",
"progress=continue",
}
var got Progress
var ok bool
for _, l := range lines {
got, ok = p.feed(l)
}
if !ok {
t.Fatalf("expected progress completion on progress=continue")
}
if got.Percent != 50.0 {
t.Fatalf("expected 50%%, got %v", got.Percent)
}
if got.ETASec != 2.5 {
t.Fatalf("expected ETA 2.5s, got %v", got.ETASec)
}
}
func TestIntegration_RealFFmpegConvert(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" || bins.FFprobe == "" {
t.Skip("ffmpeg/ffprobe not installed on host")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "source.mp4")
dst := filepath.Join(tmpDir, "out.mp4")
// Generate 4s synthetic test video with 2 audio tracks
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=4:size=640x360:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:duration=4",
"-f", "lavfi", "-i", "sine=frequency=880:duration=4",
"-map", "0:v", "-map", "1:a", "-map", "2:a",
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating test video: %v (%s)", err, string(out))
}
info, err := media.Probe(bins.FFprobe, src)
if err != nil {
t.Fatalf("probe failed: %v", err)
}
if len(info.AudioStreams) != 2 {
t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams))
}
built, err := BuildCommand(Request{
InputPath: src,
OutputPath: dst,
TrimStart: 1.0,
TrimEnd: 3.0,
Encoder: "libx264",
Preset: "ultrafast",
CRF: 24,
FPS: 30,
Speed: 2.0,
AudioMode: "merge",
Crop: CropRect{Enabled: true, X: 10, Y: 10, W: 320, H: 180},
FastStart: true,
Overwrite: true,
}, info)
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
var lastProg Progress
runner := NewRunner()
if err := runner.Run(context.Background(), bins.FFmpeg, built, func(p Progress) {
lastProg = p
}); err != nil {
t.Fatalf("runner failed: %v", err)
}
if !lastProg.Done || lastProg.Percent != 100 {
t.Fatalf("expected Done=true and 100%%, got %+v", lastProg)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("output missing: %v", err)
}
outInfo, err := media.Probe(bins.FFprobe, dst)
if err != nil {
t.Fatalf("probe output failed: %v", err)
}
if outInfo.Width != 320 || outInfo.Height != 180 {
t.Fatalf("expected cropped 320x180, got %dx%d", outInfo.Width, outInfo.Height)
}
if len(outInfo.AudioStreams) != 1 {
t.Fatalf("expected 1 merged audio stream, got %d", len(outInfo.AudioStreams))
}
if outInfo.Duration < 0.7 || outInfo.Duration > 1.4 {
t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration)
}
}
+109
View File
@@ -0,0 +1,109 @@
package convert
import (
"strconv"
"strings"
)
type Progress struct {
Percent float64 `json:"percent"`
OutTimeSec float64 `json:"outTimeSec"`
TotalSec float64 `json:"totalSec"`
FPS float64 `json:"fps"`
Speed string `json:"speed"`
TotalSize int64 `json:"totalSize"`
ETASec float64 `json:"etaSec"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
OutputBytes int64 `json:"outputBytes,omitempty"`
}
type progressParser struct {
totalSec float64
current Progress
}
func newProgressParser(totalSec float64) *progressParser {
return &progressParser{
totalSec: totalSec,
current: Progress{
TotalSec: totalSec,
},
}
}
// feed parses a single line from `ffmpeg -progress pipe:1` and returns (Progress, true) when a block completes.
func (p *progressParser) feed(line string) (Progress, bool) {
line = strings.TrimSpace(line)
if line == "" {
return Progress{}, false
}
eq := strings.IndexByte(line, '=')
if eq <= 0 {
return Progress{}, false
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
switch key {
case "fps":
if v, err := strconv.ParseFloat(val, 64); err == nil {
p.current.FPS = v
}
case "total_size":
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
p.current.TotalSize = v
}
case "out_time_us":
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
p.current.OutTimeSec = float64(v) / 1_000_000.0
}
case "out_time_ms":
// Note: ffmpeg's out_time_ms is actually microseconds in many versions,
// so only use if out_time_us wasn't set.
if p.current.OutTimeSec == 0 {
if v, err := strconv.ParseInt(val, 10, 64); err == nil && v >= 0 {
p.current.OutTimeSec = float64(v) / 1_000_000.0
}
}
case "speed":
p.current.Speed = val
case "progress":
if p.totalSec > 0 {
pct := (p.current.OutTimeSec / p.totalSec) * 100.0
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
p.current.Percent = pct
if sp := parseSpeedFactor(p.current.Speed); sp > 0 && p.current.OutTimeSec < p.totalSec {
p.current.ETASec = (p.totalSec - p.current.OutTimeSec) / sp
} else {
p.current.ETASec = 0
}
}
if val == "end" {
p.current.Percent = 100
p.current.ETASec = 0
p.current.Done = true
}
return p.current, true
}
return Progress{}, false
}
func parseSpeedFactor(speedStr string) float64 {
s := strings.TrimSpace(strings.TrimSuffix(speedStr, "x"))
if s == "" || s == "N/A" {
return 0
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v <= 0 {
return 0
}
return v
}
+168
View File
@@ -0,0 +1,168 @@
package convert
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"convert/internal/media"
)
type Runner struct {
mu sync.Mutex
cancel context.CancelFunc
stdin io.WriteCloser
active bool
}
func NewRunner() *Runner {
return &Runner{}
}
func (r *Runner) IsRunning() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
func (r *Runner) Cancel() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
if r.stdin != nil {
_, _ = io.WriteString(r.stdin, "q\n")
}
if r.cancel != nil {
r.cancel()
}
return true
}
func (r *Runner) Run(
parentCtx context.Context,
ffmpegBin string,
built *BuiltCommand,
onProgress func(Progress),
) error {
if ffmpegBin == "" {
return fmt.Errorf("ffmpeg binary not found")
}
if built == nil {
return fmt.Errorf("built command is nil")
}
if err := EnsureUniqueOutputDir(built.OutputPath); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
r.mu.Lock()
if r.active {
r.mu.Unlock()
return fmt.Errorf("conversion already in progress")
}
ctx, cancel := context.WithCancel(parentCtx)
r.cancel = cancel
r.active = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
r.active = false
r.cancel = nil
r.stdin = nil
r.mu.Unlock()
cancel()
}()
args := append([]string{"-progress", "pipe:1", "-nostats"}, built.Args...)
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
media.HideCmdWindow(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err == nil {
r.mu.Lock()
r.stdin = stdin
r.mu.Unlock()
}
var stderrBuf bytes.Buffer
cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err)
}
parser := newProgressParser(built.ExpectedDuration)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if prog, ok := parser.feed(scanner.Text()); ok && onProgress != nil {
prog.OutputPath = built.OutputPath
onProgress(prog)
}
}
waitErr := cmd.Wait()
if ctx.Err() != nil {
_ = os.Remove(built.OutputPath)
return fmt.Errorf("conversion cancelled")
}
if waitErr != nil {
_ = os.Remove(built.OutputPath)
errTail := strings.TrimSpace(stderrBuf.String())
if len(errTail) > 800 {
errTail = errTail[len(errTail)-800:]
}
if errTail != "" {
return fmt.Errorf("ffmpeg error: %s", errTail)
}
return fmt.Errorf("ffmpeg failed: %w", waitErr)
}
var outSize int64
if st, err := os.Stat(built.OutputPath); err == nil {
outSize = st.Size()
}
if onProgress != nil {
onProgress(Progress{
Percent: 100,
OutTimeSec: built.ExpectedDuration,
TotalSec: built.ExpectedDuration,
TotalSize: outSize,
OutputBytes: outSize,
OutputPath: built.OutputPath,
Done: true,
})
}
return nil
}
type limitedBuffer struct {
buf *bytes.Buffer
max int
}
func (l *limitedBuffer) Write(p []byte) (int, error) {
if l.buf.Len()+len(p) > l.max {
// keep latest tail
excess := l.buf.Len() + len(p) - l.max
if excess >= l.buf.Len() {
l.buf.Reset()
} else {
b := l.buf.Bytes()[excess:]
l.buf.Reset()
l.buf.Write(b)
}
}
return l.buf.Write(p)
}
+84
View File
@@ -0,0 +1,84 @@
package media
import (
"os"
"os/exec"
"path/filepath"
"runtime"
)
type Binaries struct {
FFmpeg string `json:"ffmpeg"`
FFprobe string `json:"ffprobe"`
Source string `json:"source"` // "custom", "bundled", "path", "winget", "missing"
}
func FindBinaries(customFFmpeg, customFFprobe string) Binaries {
exeName := func(base string) string {
if runtime.GOOS == "windows" {
return base + ".exe"
}
return base
}
ff := findSingle(exeName("ffmpeg"), customFFmpeg)
fp := findSingle(exeName("ffprobe"), customFFprobe)
source := "missing"
if ff.path != "" && fp.path != "" {
source = ff.source
} else if ff.path != "" || fp.path != "" {
if ff.path != "" {
source = ff.source
} else {
source = fp.source
}
}
return Binaries{
FFmpeg: ff.path,
FFprobe: fp.path,
Source: source,
}
}
type foundBin struct {
path string
source string
}
func findSingle(exeName, custom string) foundBin {
if custom != "" {
if st, err := os.Stat(custom); err == nil && !st.IsDir() {
return foundBin{path: custom, source: "custom"}
}
}
if exePath, err := os.Executable(); err == nil {
dir := filepath.Dir(exePath)
candidates := []string{
filepath.Join(dir, "bin", exeName),
filepath.Join(dir, exeName),
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return foundBin{path: c, source: "bundled"}
}
}
}
if p, err := exec.LookPath(exeName); err == nil {
return foundBin{path: p, source: "path"}
}
if runtime.GOOS == "windows" {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
wg := filepath.Join(localAppData, "Microsoft", "WinGet", "Links", exeName)
if st, err := os.Stat(wg); err == nil && !st.IsDir() {
return foundBin{path: wg, source: "winget"}
}
}
}
return foundBin{}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package media
import "os/exec"
func hideWindow(_ *exec.Cmd) {}
func HideCmdWindow(_ *exec.Cmd) {}
+19
View File
@@ -0,0 +1,19 @@
//go:build windows
package media
import (
"os/exec"
"syscall"
)
func hideWindow(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
}
func HideCmdWindow(cmd *exec.Cmd) {
hideWindow(cmd)
}
+220
View File
@@ -0,0 +1,220 @@
package media
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
type AudioStream struct {
Index int `json:"index"` // global stream index
AudioOrder int `json:"audioOrder"` // 0-based audio track index (0:a:<order>)
Codec string `json:"codec"`
Channels int `json:"channels"`
ChannelLayout string `json:"channelLayout"`
SampleRate string `json:"sampleRate"`
Language string `json:"language"`
Title string `json:"title"`
}
type VideoInfo struct {
Path string `json:"path"`
FileName string `json:"fileName"`
Extension string `json:"extension"`
Container string `json:"container"`
SizeBytes int64 `json:"sizeBytes"`
Duration float64 `json:"duration"`
BitRate int64 `json:"bitRate"`
Width int `json:"width"`
Height int `json:"height"`
FPS float64 `json:"fps"`
VideoCodec string `json:"videoCodec"`
PixFmt string `json:"pixFmt"`
AudioStreams []AudioStream `json:"audioStreams"`
BrowserNative bool `json:"browserNative"`
}
type rawFFprobe struct {
Format struct {
Filename string `json:"filename"`
FormatName string `json:"format_name"`
Duration string `json:"duration"`
Size string `json:"size"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
PixFmt string `json:"pix_fmt"`
AvgFrameRate string `json:"avg_frame_rate"`
RFrameRate string `json:"r_frame_rate"`
Channels int `json:"channels"`
ChannelLayout string `json:"channel_layout"`
SampleRate string `json:"sample_rate"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
func Probe(ffprobeBin, inputPath string) (*VideoInfo, error) {
if ffprobeBin == "" {
return nil, fmt.Errorf("ffprobe binary not found")
}
st, err := os.Stat(inputPath)
if err != nil {
return nil, fmt.Errorf("cannot access file: %w", err)
}
if st.IsDir() {
return nil, fmt.Errorf("path is a directory")
}
args := []string{
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
inputPath,
}
cmd := exec.Command(ffprobeBin, args...)
hideWindow(cmd)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
info, err := ParseProbeJSON(out, inputPath, st.Size())
if err != nil {
return nil, err
}
return info, nil
}
func ParseProbeJSON(data []byte, inputPath string, fileSize int64) (*VideoInfo, error) {
var raw rawFFprobe
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("invalid ffprobe json: %w", err)
}
info := &VideoInfo{
Path: inputPath,
FileName: filepath.Base(inputPath),
Extension: strings.ToLower(filepath.Ext(inputPath)),
Container: raw.Format.FormatName,
SizeBytes: fileSize,
}
if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil && d > 0 {
info.Duration = d
}
if sz, err := strconv.ParseInt(raw.Format.Size, 10, 64); err == nil && sz > 0 {
info.SizeBytes = sz
}
if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil && br > 0 {
info.BitRate = br
}
audioOrder := 0
hasVideo := false
for _, s := range raw.Streams {
switch s.CodecType {
case "video":
if !hasVideo {
hasVideo = true
info.Width = s.Width
info.Height = s.Height
info.VideoCodec = strings.ToLower(s.CodecName)
info.PixFmt = s.PixFmt
info.FPS = parseRationalFPS(s.AvgFrameRate)
if info.FPS <= 0 {
info.FPS = parseRationalFPS(s.RFrameRate)
}
if info.Duration <= 0 {
if d, err := strconv.ParseFloat(s.Duration, 64); err == nil && d > 0 {
info.Duration = d
}
}
}
case "audio":
lang := ""
title := ""
for k, v := range s.Tags {
lk := strings.ToLower(k)
if lk == "language" {
lang = v
} else if lk == "title" {
title = v
}
}
info.AudioStreams = append(info.AudioStreams, AudioStream{
Index: s.Index,
AudioOrder: audioOrder,
Codec: strings.ToLower(s.CodecName),
Channels: s.Channels,
ChannelLayout: s.ChannelLayout,
SampleRate: s.SampleRate,
Language: lang,
Title: title,
})
audioOrder++
}
}
if !hasVideo {
return nil, fmt.Errorf("no video stream found in %s", filepath.Base(inputPath))
}
info.BrowserNative = isBrowserNative(info)
return info, nil
}
func parseRationalFPS(expr string) float64 {
expr = strings.TrimSpace(expr)
if expr == "" || expr == "0/0" {
return 0
}
parts := strings.Split(expr, "/")
if len(parts) == 1 {
v, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0
}
return v
}
if len(parts) == 2 {
num, err1 := strconv.ParseFloat(parts[0], 64)
den, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil || den == 0 {
return 0
}
return num / den
}
return 0
}
func isBrowserNative(info *VideoInfo) bool {
extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm"
if !extOK {
return false
}
vOK := info.VideoCodec == "h264" || info.VideoCodec == "vp8" || info.VideoCodec == "vp9" || info.VideoCodec == "av1"
if !vOK {
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 {
switch a.Codec {
case "aac", "mp3", "opus", "vorbis", "flac":
default:
return false
}
}
return true
}
+96
View File
@@ -0,0 +1,96 @@
package media
import (
"testing"
)
func TestParseProbeJSON_MultiAudio(t *testing.T) {
sample := `{
"format": {
"filename": "clip.mp4",
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "125.500000",
"size": "104857600",
"bit_rate": "6683824"
},
"streams": [
{
"index": 0,
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"pix_fmt": "yuv420p",
"avg_frame_rate": "60/1",
"r_frame_rate": "60/1"
},
{
"index": 1,
"codec_type": "audio",
"codec_name": "aac",
"channels": 2,
"channel_layout": "stereo",
"sample_rate": "48000",
"tags": {"title": "Desktop Audio"}
},
{
"index": 2,
"codec_type": "audio",
"codec_name": "aac",
"channels": 1,
"channel_layout": "mono",
"sample_rate": "48000",
"tags": {"title": "Microphone", "language": "rus"}
}
]
}`
info, err := ParseProbeJSON([]byte(sample), `C:\clips\clip.mp4`, 123)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.Width != 1920 || info.Height != 1080 {
t.Fatalf("unexpected resolution: %dx%d", info.Width, info.Height)
}
if info.FPS != 60 {
t.Fatalf("expected 60 fps, got %v", info.FPS)
}
if info.Duration != 125.5 {
t.Fatalf("expected duration 125.5, got %v", info.Duration)
}
if len(info.AudioStreams) != 2 {
t.Fatalf("expected 2 audio streams, got %d", len(info.AudioStreams))
}
if info.AudioStreams[1].AudioOrder != 1 || info.AudioStreams[1].Title != "Microphone" || info.AudioStreams[1].Language != "rus" {
t.Fatalf("unexpected audio stream 1: %+v", info.AudioStreams[1])
}
if !info.BrowserNative {
t.Fatalf("expected mp4 h264+aac to be browser native")
}
}
func TestParseProbeJSON_NoVideo(t *testing.T) {
sample := `{"format":{"duration":"10"},"streams":[{"index":0,"codec_type":"audio","codec_name":"mp3"}]}`
if _, err := ParseProbeJSON([]byte(sample), "audio.mp3", 100); err == nil {
t.Fatalf("expected error when no video stream exists")
}
}
func TestParseRationalFPS(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"60/1", 60},
{"30000/1001", 30000.0 / 1001.0},
{"0/0", 0},
{"", 0},
{"24", 24},
}
for _, tc := range cases {
got := parseRationalFPS(tc.in)
if got != tc.want {
t.Fatalf("parseRationalFPS(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+300
View File
@@ -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)
}
+87
View File
@@ -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)
}
}