diff --git a/DONE.md b/DONE.md index 1228766..73ab3b5 100644 --- a/DONE.md +++ b/DONE.md @@ -30,7 +30,15 @@ This file tracks completed features, fixes, and milestones. - **UI Polish** - "Run Benchmark" button highlighted (HighImportance) on first run - Returns to normal styling after initial benchmark - - Guides new users to run initial benchmark + - Guides new users to run initial benchmark + +- ✅ **AI Upscale Integration (Real-ESRGAN)** + - Added model presets with anime/general variants + - Processing presets (Ultra Fast → Maximum Quality) with tile/TTA tuning + - Upscale factor selection + output adjustment slider + - Tile size, output frame format, GPU and thread controls + - ncnn backend pipeline (extract → AI upscale → reassemble) + - Filters and frame rate conversion applied before AI upscaling - ✅ **Bitrate Preset Simplification** - Reduced from 13 confusing options to 6 clear presets diff --git a/TODO.md b/TODO.md index 88028ed..ca29dc6 100644 --- a/TODO.md +++ b/TODO.md @@ -68,6 +68,7 @@ This file tracks upcoming features, improvements, and known issues. - Reduce module video pane min sizes to allow GNOME snapping - Cache/temp directory setting with SSD recommendation - Frame interpolation presets in Filters with Upscale linkage + - Real-ESRGAN AI upscale controls with ncnn pipeline (models, presets, tiles, TTA) *Last Updated: 2025-12-20* diff --git a/main.go b/main.go index d5ab34a..b89fd25 100644 --- a/main.go +++ b/main.go @@ -814,6 +814,21 @@ type appState struct { upscaleAIEnabled bool // Use AI upscaling if available upscaleAIModel string // realesrgan, realesrgan-anime, none upscaleAIAvailable bool // Runtime detection + upscaleAIBackend string // ncnn, python + upscaleAIPreset string // Ultra Fast, Fast, Balanced, High Quality, Maximum Quality + upscaleAIScale float64 // Base outscale when not matching target + upscaleAIScaleUseTarget bool // Use target resolution to compute scale + upscaleAIOutputAdjust float64 // Post scale adjustment multiplier + upscaleAIFaceEnhance bool // Face enhancement (Python only) + upscaleAIDenoise float64 // Denoise strength (0-1, model-specific) + upscaleAITile int // Tile size for AI upscaling + upscaleAIGPU int // GPU index (if supported) + upscaleAIGPUAuto bool // Auto-select GPU + upscaleAIThreadsLoad int // Threading for load stage + upscaleAIThreadsProc int // Threading for processing stage + upscaleAIThreadsSave int // Threading for save stage + upscaleAITTA bool // Test-time augmentation + upscaleAIOutputFormat string // png, jpg, webp upscaleApplyFilters bool // Apply filters from Filters module upscaleFilterChain []string // Transferred filters from Filters module upscaleFrameRate string // Source, 24, 30, 60, or custom @@ -4385,10 +4400,29 @@ func (s *appState) executeUpscaleJob(ctx context.Context, job *queue.Job, progre if v, ok := cfg["preserveAR"].(bool); ok { preserveAR = v } - // useAI := cfg["useAI"].(bool) // TODO: Implement AI upscaling in future + useAI := false + if v, ok := cfg["useAI"].(bool); ok { + useAI = v + } + aiBackend, _ := cfg["aiBackend"].(string) + aiModel, _ := cfg["aiModel"].(string) + aiScale := toFloat(cfg["aiScale"]) + aiScaleUseTarget, _ := cfg["aiScaleUseTarget"].(bool) + aiOutputAdjust := toFloat(cfg["aiOutputAdjust"]) + aiFaceEnhance, _ := cfg["aiFaceEnhance"].(bool) + aiDenoise := toFloat(cfg["aiDenoise"]) + aiTile := int(toFloat(cfg["aiTile"])) + aiGPU := int(toFloat(cfg["aiGPU"])) + aiGPUAuto, _ := cfg["aiGPUAuto"].(bool) + aiThreadsLoad := int(toFloat(cfg["aiThreadsLoad"])) + aiThreadsProc := int(toFloat(cfg["aiThreadsProc"])) + aiThreadsSave := int(toFloat(cfg["aiThreadsSave"])) + aiTTA, _ := cfg["aiTTA"].(bool) + aiOutputFormat, _ := cfg["aiOutputFormat"].(string) applyFilters := cfg["applyFilters"].(bool) frameRate, _ := cfg["frameRate"].(string) useMotionInterp, _ := cfg["useMotionInterpolation"].(bool) + sourceFrameRate := toFloat(cfg["sourceFrameRate"]) if progressCallback != nil { progressCallback(0) @@ -4410,39 +4444,293 @@ func (s *appState) executeUpscaleJob(ctx context.Context, job *queue.Job, progre } // Build filter chain - var filters []string + var baseFilters []string // Add filters from Filters module if requested if applyFilters { if filterChain, ok := cfg["filterChain"].([]interface{}); ok { for _, f := range filterChain { if filterStr, ok := f.(string); ok { - filters = append(filters, filterStr) + baseFilters = append(baseFilters, filterStr) } } + } else if filterChain, ok := cfg["filterChain"].([]string); ok { + baseFilters = append(baseFilters, filterChain...) } } - // Add scale filter (preserve aspect by default) - scaleFilter := buildUpscaleFilter(targetWidth, targetHeight, method, preserveAR) - logging.Debug(logging.CatFFMPEG, "upscale: target=%dx%d preserveAR=%v method=%s filter=%s", targetWidth, targetHeight, preserveAR, method, scaleFilter) - filters = append(filters, scaleFilter) - // Add frame rate conversion if requested if frameRate != "" && frameRate != "Source" { if useMotionInterp { // Use motion interpolation for smooth frame rate changes - filters = append(filters, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate)) + baseFilters = append(baseFilters, fmt.Sprintf("minterpolate=fps=%s:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1", frameRate)) } else { // Simple frame rate change (duplicates/drops frames) - filters = append(filters, "fps="+frameRate) + baseFilters = append(baseFilters, "fps="+frameRate) } } + if useAI { + if aiBackend != "ncnn" { + return fmt.Errorf("AI upscaling backend not available") + } + + if aiModel == "" { + aiModel = "realesrgan-x4plus" + } + if aiOutputFormat == "" { + aiOutputFormat = "png" + } + if aiOutputAdjust <= 0 { + aiOutputAdjust = 1.0 + } + if aiScale <= 0 { + aiScale = 4.0 + } + if aiThreadsLoad <= 0 { + aiThreadsLoad = 1 + } + if aiThreadsProc <= 0 { + aiThreadsProc = 2 + } + if aiThreadsSave <= 0 { + aiThreadsSave = 2 + } + + outScale := aiScale + if aiScaleUseTarget { + switch targetPreset { + case "", "Match Source": + outScale = 1.0 + case "2X (relative)": + outScale = 2.0 + case "4X (relative)": + outScale = 4.0 + default: + if sourceHeight > 0 && targetHeight > 0 { + outScale = float64(targetHeight) / float64(sourceHeight) + } + } + } + outScale *= aiOutputAdjust + if outScale < 0.1 { + outScale = 0.1 + } else if outScale > 8.0 { + outScale = 8.0 + } + + if progressCallback != nil { + progressCallback(1) + } + + workDir, err := os.MkdirTemp(utils.TempDir(), "vt-ai-upscale-") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(workDir) + + inputFramesDir := filepath.Join(workDir, "frames_in") + outputFramesDir := filepath.Join(workDir, "frames_out") + if err := os.MkdirAll(inputFramesDir, 0o755); err != nil { + return fmt.Errorf("failed to create frames dir: %w", err) + } + if err := os.MkdirAll(outputFramesDir, 0o755); err != nil { + return fmt.Errorf("failed to create frames dir: %w", err) + } + + var preFilter string + if len(baseFilters) > 0 { + preFilter = strings.Join(baseFilters, ",") + } + + frameExt := strings.ToLower(aiOutputFormat) + if frameExt == "jpeg" { + frameExt = "jpg" + } + framePattern := filepath.Join(inputFramesDir, "frame_%08d."+frameExt) + extractArgs := []string{"-y", "-hide_banner", "-i", inputPath} + if preFilter != "" { + extractArgs = append(extractArgs, "-vf", preFilter) + } + extractArgs = append(extractArgs, "-start_number", "0", framePattern) + + logFile, logPath, _ := createConversionLog(inputPath, outputPath, extractArgs) + if logFile != nil { + fmt.Fprintln(logFile, "Stage: extract frames for AI upscaling") + } + + runFFmpegWithProgress := func(args []string, duration float64, startPct, endPct float64) error { + cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) + utils.ApplyNoWindow(cmd) + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg: %w", err) + } + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + if logFile != nil { + fmt.Fprintln(logFile, line) + } + if strings.Contains(line, "time=") && duration > 0 { + if idx := strings.Index(line, "time="); idx != -1 { + timeStr := line[idx+5:] + if spaceIdx := strings.Index(timeStr, " "); spaceIdx != -1 { + timeStr = timeStr[:spaceIdx] + } + var h, m int + var s float64 + if _, err := fmt.Sscanf(timeStr, "%d:%d:%f", &h, &m, &s); err == nil { + currentTime := float64(h*3600+m*60) + s + progress := startPct + ((currentTime / duration) * (endPct - startPct)) + if progressCallback != nil { + progressCallback(progress) + } + } + } + } + } + return cmd.Wait() + } + + duration := toFloat(cfg["duration"]) + if err := runFFmpegWithProgress(extractArgs, duration, 1, 35); err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during extraction at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("failed to extract frames: %w", err) + } + + if progressCallback != nil { + progressCallback(40) + } + + aiArgs := []string{ + "-i", inputFramesDir, + "-o", outputFramesDir, + "-n", aiModel, + "-s", fmt.Sprintf("%.2f", outScale), + "-j", fmt.Sprintf("%d:%d:%d", aiThreadsLoad, aiThreadsProc, aiThreadsSave), + "-f", frameExt, + } + if aiTile > 0 { + aiArgs = append(aiArgs, "-t", strconv.Itoa(aiTile)) + } + if !aiGPUAuto { + aiArgs = append(aiArgs, "-g", strconv.Itoa(aiGPU)) + } + if aiTTA { + aiArgs = append(aiArgs, "-x") + } + if aiModel == "realesr-general-x4v3" { + aiArgs = append(aiArgs, "-dn", fmt.Sprintf("%.2f", aiDenoise)) + } + if aiFaceEnhance && logFile != nil { + fmt.Fprintln(logFile, "Note: face enhancement requested but not supported in ncnn backend") + } + + if logFile != nil { + fmt.Fprintln(logFile, "Stage: Real-ESRGAN") + fmt.Fprintf(logFile, "Command: realesrgan-ncnn-vulkan %s\n", strings.Join(aiArgs, " ")) + } + + aiCmd := exec.CommandContext(ctx, "realesrgan-ncnn-vulkan", aiArgs...) + utils.ApplyNoWindow(aiCmd) + aiOut, err := aiCmd.CombinedOutput() + if logFile != nil && len(aiOut) > 0 { + fmt.Fprintln(logFile, string(aiOut)) + } + if err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during AI upscale at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("AI upscaling failed: %w", err) + } + + if progressCallback != nil { + progressCallback(70) + } + + if frameRate == "" || frameRate == "Source" { + if sourceFrameRate <= 0 { + if src, err := probeVideo(inputPath); err == nil && src != nil { + sourceFrameRate = src.FrameRate + } + } + } else if fps, err := strconv.ParseFloat(frameRate, 64); err == nil { + sourceFrameRate = fps + } + + if sourceFrameRate <= 0 { + sourceFrameRate = 30.0 + } + + reassemblePattern := filepath.Join(outputFramesDir, "frame_%08d."+frameExt) + reassembleArgs := []string{ + "-y", + "-hide_banner", + "-framerate", fmt.Sprintf("%.3f", sourceFrameRate), + "-i", reassemblePattern, + "-i", inputPath, + "-map", "0:v:0", + "-map", "1:a?", + } + + // Final scale to ensure target height/aspect (optional) + if targetPreset != "" && targetPreset != "Match Source" { + finalScale := buildUpscaleFilter(targetWidth, targetHeight, method, preserveAR) + reassembleArgs = append(reassembleArgs, "-vf", finalScale) + } + + reassembleArgs = append(reassembleArgs, + "-c:v", "libx264", + "-preset", "slow", + "-crf", "0", + "-pix_fmt", "yuv420p", + "-c:a", "copy", + "-shortest", + outputPath, + ) + + if logFile != nil { + fmt.Fprintln(logFile, "Stage: reassemble") + } + + if err := runFFmpegWithProgress(reassembleArgs, duration, 70, 100); err != nil { + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: failed during reassemble at %s\nError: %v\n", time.Now().Format(time.RFC3339), err) + _ = logFile.Close() + } + return fmt.Errorf("failed to reassemble upscaled video: %w", err) + } + + if logFile != nil { + fmt.Fprintf(logFile, "\nStatus: completed at %s\n", time.Now().Format(time.RFC3339)) + _ = logFile.Close() + job.LogPath = logPath + } + + if progressCallback != nil { + progressCallback(100) + } + + return nil + } + + // Add scale filter (preserve aspect by default) + scaleFilter := buildUpscaleFilter(targetWidth, targetHeight, method, preserveAR) + logging.Debug(logging.CatFFMPEG, "upscale: target=%dx%d preserveAR=%v method=%s filter=%s", targetWidth, targetHeight, preserveAR, method, scaleFilter) + baseFilters = append(baseFilters, scaleFilter) + // Combine filters var vfilter string - if len(filters) > 0 { - vfilter = strings.Join(filters, ",") + if len(baseFilters) > 0 { + vfilter = strings.Join(baseFilters, ",") } // Build FFmpeg command @@ -4804,6 +5092,18 @@ func buildFFmpegCommandFromJob(job *queue.Job) string { args = append(args, "-ac", "2") case "5.1": args = append(args, "-ac", "6") + case "Left to Stereo": + // Copy left channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c0|c1=c0") + case "Right to Stereo": + // Copy right channel to both left and right + args = append(args, "-af", "pan=stereo|c0=c1|c1=c1") + case "Mix to Stereo": + // Downmix both channels together, then duplicate to L+R + args = append(args, "-af", "pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1") + case "Swap L/R": + // Swap left and right channels + args = append(args, "-af", "pan=stereo|c0=c1|c1=c0") } } } @@ -6699,7 +6999,16 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject { audioBitrateSelect.SetSelected(state.convert.AudioBitrate) // Audio Channels - audioChannelsSelect := widget.NewSelect([]string{"Source", "Mono", "Stereo", "5.1"}, func(value string) { + audioChannelsSelect := widget.NewSelect([]string{ + "Source", + "Mono", + "Stereo", + "5.1", + "Left to Stereo", + "Right to Stereo", + "Mix to Stereo", + "Swap L/R", + }, func(value string) { state.convert.AudioChannels = value logging.Debug(logging.CatUI, "audio channels set to %s", value) }) @@ -12804,15 +13113,29 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { state.upscaleTargetRes = "Match Source" } if state.upscaleAIModel == "" { - state.upscaleAIModel = "realesrgan" // General purpose AI model + state.upscaleAIModel = "realesrgan-x4plus" // General purpose AI model } if state.upscaleFrameRate == "" { state.upscaleFrameRate = "Source" } + if state.upscaleAIPreset == "" { + state.upscaleAIPreset = "Balanced" + state.upscaleAIScale = 4.0 + state.upscaleAIScaleUseTarget = true + state.upscaleAIOutputAdjust = 1.0 + state.upscaleAIDenoise = 0.5 + state.upscaleAITile = 512 + state.upscaleAIOutputFormat = "png" + state.upscaleAIGPUAuto = true + state.upscaleAIThreadsLoad = 1 + state.upscaleAIThreadsProc = 2 + state.upscaleAIThreadsSave = 2 + } // Check AI availability on first load - if !state.upscaleAIAvailable { - state.upscaleAIAvailable = checkAIUpscaleAvailable() + if state.upscaleAIBackend == "" { + state.upscaleAIBackend = detectAIUpscaleBackend() + state.upscaleAIAvailable = state.upscaleAIBackend != "" } if len(state.filterActiveChain) > 0 { state.upscaleFilterChain = append([]string{}, state.filterActiveChain...) @@ -12950,23 +13273,67 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { widget.NewLabel("Motion interpolation creates smooth in-between frames"), )) + aiModelOptions := aiUpscaleModelOptions() + aiModelLabel := aiUpscaleModelLabel(state.upscaleAIModel) + if aiModelLabel == "" && len(aiModelOptions) > 0 { + aiModelLabel = aiModelOptions[0] + } + // AI Upscaling Section var aiSection *widget.Card if state.upscaleAIAvailable { - aiModelSelect := widget.NewSelect([]string{ - "realesrgan (General Purpose)", - "realesrgan-anime (Anime/Animation)", - }, func(s string) { - if strings.Contains(s, "anime") { - state.upscaleAIModel = "realesrgan-anime" - } else { - state.upscaleAIModel = "realesrgan" + var aiTileSelect *widget.Select + var aiTTACheck *widget.Check + var aiDenoiseSlider *widget.Slider + var denoiseHint *widget.Label + + applyAIPreset := func(preset string) { + state.upscaleAIPreset = preset + switch preset { + case "Ultra Fast": + state.upscaleAITile = 800 + state.upscaleAITTA = false + case "Fast": + state.upscaleAITile = 800 + state.upscaleAITTA = false + case "Balanced": + state.upscaleAITile = 512 + state.upscaleAITTA = false + case "High Quality": + state.upscaleAITile = 256 + state.upscaleAITTA = false + case "Maximum Quality": + state.upscaleAITile = 0 + state.upscaleAITTA = true + } + if aiTileSelect != nil { + switch state.upscaleAITile { + case 256: + aiTileSelect.SetSelected("256") + case 512: + aiTileSelect.SetSelected("512") + case 800: + aiTileSelect.SetSelected("800") + default: + aiTileSelect.SetSelected("Auto") + } + } + if aiTTACheck != nil { + aiTTACheck.SetChecked(state.upscaleAITTA) + } + } + + updateDenoiseAvailability := func(model string) { + if aiDenoiseSlider == nil || denoiseHint == nil { + return + } + if model == "realesr-general-x4v3" { + aiDenoiseSlider.Enable() + denoiseHint.SetText("Denoise available on General Tiny model") + } else { + aiDenoiseSlider.Disable() + denoiseHint.SetText("Denoise only supported on General Tiny model") } - }) - if strings.Contains(state.upscaleAIModel, "anime") { - aiModelSelect.SetSelected("realesrgan-anime (Anime/Animation)") - } else { - aiModelSelect.SetSelected("realesrgan (General Purpose)") } aiEnabledCheck := widget.NewCheck("Use AI Upscaling", func(checked bool) { @@ -12974,6 +13341,154 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { }) aiEnabledCheck.SetChecked(state.upscaleAIEnabled) + aiModelSelect := widget.NewSelect(aiModelOptions, func(s string) { + state.upscaleAIModel = aiUpscaleModelID(s) + aiModelLabel = s + updateDenoiseAvailability(state.upscaleAIModel) + }) + if aiModelLabel != "" { + aiModelSelect.SetSelected(aiModelLabel) + } + + aiPresetSelect := widget.NewSelect([]string{"Ultra Fast", "Fast", "Balanced", "High Quality", "Maximum Quality"}, func(s string) { + applyAIPreset(s) + }) + aiPresetSelect.SetSelected(state.upscaleAIPreset) + + aiScaleSelect := widget.NewSelect([]string{"Match Target", "1x", "2x", "3x", "4x", "8x"}, func(s string) { + if s == "Match Target" { + state.upscaleAIScaleUseTarget = true + return + } + state.upscaleAIScaleUseTarget = false + switch s { + case "1x": + state.upscaleAIScale = 1 + case "2x": + state.upscaleAIScale = 2 + case "3x": + state.upscaleAIScale = 3 + case "4x": + state.upscaleAIScale = 4 + case "8x": + state.upscaleAIScale = 8 + } + }) + if state.upscaleAIScaleUseTarget { + aiScaleSelect.SetSelected("Match Target") + } else { + aiScaleSelect.SetSelected(fmt.Sprintf("%.0fx", state.upscaleAIScale)) + } + + aiAdjustLabel := widget.NewLabel(fmt.Sprintf("Adjustment: %.2fx", state.upscaleAIOutputAdjust)) + aiAdjustSlider := widget.NewSlider(0.5, 2.0) + aiAdjustSlider.Value = state.upscaleAIOutputAdjust + aiAdjustSlider.Step = 0.05 + aiAdjustSlider.OnChanged = func(v float64) { + state.upscaleAIOutputAdjust = v + aiAdjustLabel.SetText(fmt.Sprintf("Adjustment: %.2fx", v)) + } + + aiDenoiseLabel := widget.NewLabel(fmt.Sprintf("Denoise: %.2f", state.upscaleAIDenoise)) + aiDenoiseSlider = widget.NewSlider(0.0, 1.0) + aiDenoiseSlider.Value = state.upscaleAIDenoise + aiDenoiseSlider.Step = 0.05 + aiDenoiseSlider.OnChanged = func(v float64) { + state.upscaleAIDenoise = v + aiDenoiseLabel.SetText(fmt.Sprintf("Denoise: %.2f", v)) + } + + aiTileSelect = widget.NewSelect([]string{"Auto", "256", "512", "800"}, func(s string) { + switch s { + case "Auto": + state.upscaleAITile = 0 + case "256": + state.upscaleAITile = 256 + case "512": + state.upscaleAITile = 512 + case "800": + state.upscaleAITile = 800 + } + }) + switch state.upscaleAITile { + case 256: + aiTileSelect.SetSelected("256") + case 512: + aiTileSelect.SetSelected("512") + case 800: + aiTileSelect.SetSelected("800") + default: + aiTileSelect.SetSelected("Auto") + } + + aiOutputFormatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, func(s string) { + state.upscaleAIOutputFormat = strings.ToLower(s) + }) + switch strings.ToLower(state.upscaleAIOutputFormat) { + case "jpg", "jpeg": + aiOutputFormatSelect.SetSelected("JPG") + case "webp": + aiOutputFormatSelect.SetSelected("WEBP") + default: + aiOutputFormatSelect.SetSelected("PNG") + } + + aiFaceCheck := widget.NewCheck("Face Enhancement (requires Python/GFPGAN)", func(checked bool) { + state.upscaleAIFaceEnhance = checked + }) + aiFaceAvailable := checkAIFaceEnhanceAvailable(state.upscaleAIBackend) + if !aiFaceAvailable { + aiFaceCheck.Disable() + } + aiFaceCheck.SetChecked(state.upscaleAIFaceEnhance && aiFaceAvailable) + + aiTTACheck = widget.NewCheck("Enable TTA (slower, higher quality)", func(checked bool) { + state.upscaleAITTA = checked + }) + aiTTACheck.SetChecked(state.upscaleAITTA) + + aiGPUSelect := widget.NewSelect([]string{"Auto", "0", "1", "2"}, func(s string) { + if s == "Auto" { + state.upscaleAIGPUAuto = true + return + } + state.upscaleAIGPUAuto = false + if gpu, err := strconv.Atoi(s); err == nil { + state.upscaleAIGPU = gpu + } + }) + if state.upscaleAIGPUAuto { + aiGPUSelect.SetSelected("Auto") + } else { + aiGPUSelect.SetSelected(strconv.Itoa(state.upscaleAIGPU)) + } + + threadOptions := []string{"1", "2", "3", "4"} + aiThreadsLoad := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsLoad = v + } + }) + aiThreadsLoad.SetSelected(strconv.Itoa(state.upscaleAIThreadsLoad)) + + aiThreadsProc := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsProc = v + } + }) + aiThreadsProc.SetSelected(strconv.Itoa(state.upscaleAIThreadsProc)) + + aiThreadsSave := widget.NewSelect(threadOptions, func(s string) { + if v, err := strconv.Atoi(s); err == nil { + state.upscaleAIThreadsSave = v + } + }) + aiThreadsSave.SetSelected(strconv.Itoa(state.upscaleAIThreadsSave)) + + denoiseHint = widget.NewLabel("") + denoiseHint.TextStyle = fyne.TextStyle{Italic: true} + updateDenoiseAvailability(state.upscaleAIModel) + aiSection = widget.NewCard("AI Upscaling", "✓ Available", container.NewVBox( widget.NewLabel("Real-ESRGAN detected - enhanced quality available"), aiEnabledCheck, @@ -12981,6 +13496,36 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { widget.NewLabel("AI Model:"), aiModelSelect, ), + container.NewGridWithColumns(2, + widget.NewLabel("Processing Preset:"), + aiPresetSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Upscale Factor:"), + aiScaleSelect, + ), + container.NewVBox(aiAdjustLabel, aiAdjustSlider), + container.NewVBox(aiDenoiseLabel, aiDenoiseSlider, denoiseHint), + container.NewGridWithColumns(2, + widget.NewLabel("Tile Size:"), + aiTileSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Output Frames:"), + aiOutputFormatSelect, + ), + aiFaceCheck, + aiTTACheck, + widget.NewSeparator(), + widget.NewLabel("Advanced (ncnn backend)"), + container.NewGridWithColumns(2, + widget.NewLabel("GPU:"), + aiGPUSelect, + ), + container.NewGridWithColumns(2, + widget.NewLabel("Threads (Load/Proc/Save):"), + container.NewGridWithColumns(3, aiThreadsLoad, aiThreadsProc, aiThreadsSave), + ), widget.NewLabel("Note: AI upscaling is slower but produces higher quality results"), )) } else { @@ -13050,9 +13595,25 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { "preserveAR": preserveAspect, "useAI": state.upscaleAIEnabled && state.upscaleAIAvailable, "aiModel": state.upscaleAIModel, + "aiBackend": state.upscaleAIBackend, + "aiPreset": state.upscaleAIPreset, + "aiScale": state.upscaleAIScale, + "aiScaleUseTarget": state.upscaleAIScaleUseTarget, + "aiOutputAdjust": state.upscaleAIOutputAdjust, + "aiFaceEnhance": state.upscaleAIFaceEnhance, + "aiDenoise": state.upscaleAIDenoise, + "aiTile": float64(state.upscaleAITile), + "aiGPU": float64(state.upscaleAIGPU), + "aiGPUAuto": state.upscaleAIGPUAuto, + "aiThreadsLoad": float64(state.upscaleAIThreadsLoad), + "aiThreadsProc": float64(state.upscaleAIThreadsProc), + "aiThreadsSave": float64(state.upscaleAIThreadsSave), + "aiTTA": state.upscaleAITTA, + "aiOutputFormat": state.upscaleAIOutputFormat, "applyFilters": state.upscaleApplyFilters, "filterChain": state.upscaleFilterChain, "duration": state.upscaleFile.Duration, + "sourceFrameRate": state.upscaleFile.FrameRate, "frameRate": state.upscaleFrameRate, "useMotionInterpolation": state.upscaleMotionInterpolation, }, @@ -13124,27 +13685,83 @@ func buildUpscaleView(state *appState) fyne.CanvasObject { return container.NewBorder(topBar, bottomBar, nil, nil, content) } -// checkAIUpscaleAvailable checks if Real-ESRGAN is available on the system -func checkAIUpscaleAvailable() bool { - // Check for realesrgan-ncnn-vulkan (most common binary distribution) - cmd := exec.Command("realesrgan-ncnn-vulkan", "--help") - if err := cmd.Run(); err == nil { - return true +// detectAIUpscaleBackend returns the available Real-ESRGAN backend ("ncnn", "python", or ""). +func detectAIUpscaleBackend() string { + if _, err := exec.LookPath("realesrgan-ncnn-vulkan"); err == nil { + return "ncnn" } - // Check for Python-based Real-ESRGAN - cmd = exec.Command("python3", "-c", "import realesrgan") + cmd := exec.Command("python3", "-c", "import realesrgan") if err := cmd.Run(); err == nil { - return true + return "python" } - // Check for alternative Python command cmd = exec.Command("python", "-c", "import realesrgan") if err := cmd.Run(); err == nil { - return true + return "python" } - return false + return "" +} + +// checkAIFaceEnhanceAvailable verifies whether face enhancement tooling is available. +func checkAIFaceEnhanceAvailable(backend string) bool { + if backend != "python" { + return false + } + cmd := exec.Command("python3", "-c", "import realesrgan, gfpgan") + if err := cmd.Run(); err == nil { + return true + } + cmd = exec.Command("python", "-c", "import realesrgan, gfpgan") + return cmd.Run() == nil +} + +func aiUpscaleModelOptions() []string { + return []string{ + "General (RealESRGAN_x4plus)", + "Anime/Illustration (RealESRGAN_x4plus_anime_6B)", + "Anime Video (realesr-animevideov3)", + "General Tiny (realesr-general-x4v3)", + "2x General (RealESRGAN_x2plus)", + "Clean Restore (realesrnet-x4plus)", + } +} + +func aiUpscaleModelID(label string) string { + switch label { + case "Anime/Illustration (RealESRGAN_x4plus_anime_6B)": + return "realesrgan-x4plus-anime" + case "Anime Video (realesr-animevideov3)": + return "realesr-animevideov3" + case "General Tiny (realesr-general-x4v3)": + return "realesr-general-x4v3" + case "2x General (RealESRGAN_x2plus)": + return "realesrgan-x2plus" + case "Clean Restore (realesrnet-x4plus)": + return "realesrnet-x4plus" + default: + return "realesrgan-x4plus" + } +} + +func aiUpscaleModelLabel(modelID string) string { + switch modelID { + case "realesrgan-x4plus-anime": + return "Anime/Illustration (RealESRGAN_x4plus_anime_6B)" + case "realesr-animevideov3": + return "Anime Video (realesr-animevideov3)" + case "realesr-general-x4v3": + return "General Tiny (realesr-general-x4v3)" + case "realesrgan-x2plus": + return "2x General (RealESRGAN_x2plus)" + case "realesrnet-x4plus": + return "Clean Restore (realesrnet-x4plus)" + case "realesrgan-x4plus": + return "General (RealESRGAN_x4plus)" + default: + return "" + } } // parseResolutionPreset parses resolution preset strings and returns target dimensions and whether to preserve aspect.