Add Real-ESRGAN upscale controls and pipeline

This commit is contained in:
Stu Leak 2025-12-20 20:55:21 -05:00
parent 28e2f40b75
commit 271c83ec74
3 changed files with 668 additions and 42 deletions

10
DONE.md
View File

@ -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

View File

@ -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*

699
main.go
View File

@ -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.