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,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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package media
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func hideWindow(_ *exec.Cmd) {}
|
||||
|
||||
func HideCmdWindow(_ *exec.Cmd) {}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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