Add progress bars to In Progress tab and fix lossless quality compatibility
In Progress Tab Enhancements: - Added animated striped progress bars to in-progress jobs - Exported ModuleColor function for reuse across modules - Shows real-time progress (0-100%) with module-specific colors - Progress updates automatically as jobs run - Maintains consistent visual style with queue view Lossless Quality Preset Improvements: - H.265 and AV1 now support all bitrate modes with lossless quality - Lossless with Target Size mode now works for H.265/AV1 - H.264 and MPEG-2 no longer show "Lossless" option (codec limitation) - Dynamic quality dropdown updates based on selected codec - Automatic fallback to "Near-Lossless" when switching from lossless-capable codec to non-lossless codec Quality Options Logic: - Base options: Draft, Standard, Balanced, High, Near-Lossless - "Lossless" only appears for H.265 and AV1 - codecSupportsLossless() helper function checks compatibility - updateQualityOptions() refreshes dropdown when codec changes Lossless + Bitrate Mode Combinations: - Lossless + CRF: Forces CRF 0 for perfect quality - Lossless + CBR: Constant bitrate with lossless quality - Lossless + VBR: Variable bitrate with lossless quality - Lossless + Target Size: Calculates bitrate for exact file size with best possible quality (now allowed for H.265/AV1) Technical Implementation: - Added Progress field to ui.HistoryEntry struct - Exported StripedProgress widget and ModuleColor function - updateQualityOptions() function dynamically filters quality presets - updateEncodingControls() handles lossless modes per codec - Descriptive hints explain each lossless+bitrate combination This allows professional workflows where lossless quality is desired but file size constraints still need to be met using Target Size mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
12b2b221b9
commit
86d2f2b835
|
|
@ -40,6 +40,7 @@ type HistoryEntry struct {
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
Error string
|
Error string
|
||||||
FFmpegCmd string
|
FFmpegCmd string
|
||||||
|
Progress float64 // 0.0 to 1.0 for in-progress jobs
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildMainMenu creates the main menu view with module tiles grouped by category
|
// BuildMainMenu creates the main menu view with module tiles grouped by category
|
||||||
|
|
@ -258,6 +259,21 @@ func buildHistoryItem(
|
||||||
timeLabel := widget.NewLabel(timeStr)
|
timeLabel := widget.NewLabel(timeStr)
|
||||||
timeLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
timeLabel.TextStyle = fyne.TextStyle{Monospace: true}
|
||||||
|
|
||||||
|
// Progress bar for in-progress jobs
|
||||||
|
contentItems := []fyne.CanvasObject{
|
||||||
|
container.NewHBox(headerItems...),
|
||||||
|
titleLabel,
|
||||||
|
timeLabel,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Status == queue.JobStatusRunning || entry.Status == queue.JobStatusPending {
|
||||||
|
// Add progress bar for active jobs
|
||||||
|
moduleCol := ModuleColor(entry.Type)
|
||||||
|
progressBar := NewStripedProgress(moduleCol)
|
||||||
|
progressBar.SetProgress(entry.Progress)
|
||||||
|
contentItems = append(contentItems, progressBar)
|
||||||
|
}
|
||||||
|
|
||||||
// Status color bar
|
// Status color bar
|
||||||
statusColor := GetStatusColor(entry.Status)
|
statusColor := GetStatusColor(entry.Status)
|
||||||
statusRect := canvas.NewRectangle(statusColor)
|
statusRect := canvas.NewRectangle(statusColor)
|
||||||
|
|
@ -265,11 +281,7 @@ func buildHistoryItem(
|
||||||
|
|
||||||
content := container.NewBorder(
|
content := container.NewBorder(
|
||||||
nil, nil, statusRect, nil,
|
nil, nil, statusRect, nil,
|
||||||
container.NewVBox(
|
container.NewVBox(contentItems...),
|
||||||
container.NewHBox(headerItems...),
|
|
||||||
titleLabel,
|
|
||||||
timeLabel,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
card := canvas.NewRectangle(bgColor)
|
card := canvas.NewRectangle(bgColor)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import (
|
||||||
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stripedProgress renders a progress bar with a tinted stripe pattern.
|
// StripedProgress renders a progress bar with a tinted stripe pattern.
|
||||||
type stripedProgress struct {
|
type StripedProgress struct {
|
||||||
widget.BaseWidget
|
widget.BaseWidget
|
||||||
progress float64
|
progress float64
|
||||||
color color.Color
|
color color.Color
|
||||||
|
|
@ -25,8 +25,9 @@ type stripedProgress struct {
|
||||||
offset float64
|
offset float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStripedProgress(col color.Color) *stripedProgress {
|
// NewStripedProgress creates a new striped progress bar with the given color
|
||||||
sp := &stripedProgress{
|
func NewStripedProgress(col color.Color) *StripedProgress {
|
||||||
|
sp := &StripedProgress{
|
||||||
progress: 0,
|
progress: 0,
|
||||||
color: col,
|
color: col,
|
||||||
bg: color.RGBA{R: 34, G: 38, B: 48, A: 255}, // dark neutral
|
bg: color.RGBA{R: 34, G: 38, B: 48, A: 255}, // dark neutral
|
||||||
|
|
@ -35,7 +36,8 @@ func newStripedProgress(col color.Color) *stripedProgress {
|
||||||
return sp
|
return sp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stripedProgress) SetProgress(p float64) {
|
// SetProgress updates the progress value (0.0 to 1.0)
|
||||||
|
func (s *StripedProgress) SetProgress(p float64) {
|
||||||
if p < 0 {
|
if p < 0 {
|
||||||
p = 0
|
p = 0
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +48,7 @@ func (s *stripedProgress) SetProgress(p float64) {
|
||||||
s.Refresh()
|
s.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
func (s *StripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
||||||
bgRect := canvas.NewRectangle(s.bg)
|
bgRect := canvas.NewRectangle(s.bg)
|
||||||
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
|
fillRect := canvas.NewRectangle(applyAlpha(s.color, 200))
|
||||||
stripes := canvas.NewRaster(func(w, h int) image.Image {
|
stripes := canvas.NewRaster(func(w, h int) image.Image {
|
||||||
|
|
@ -79,7 +81,7 @@ func (s *stripedProgress) CreateRenderer() fyne.WidgetRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
type stripedProgressRenderer struct {
|
type stripedProgressRenderer struct {
|
||||||
bar *stripedProgress
|
bar *StripedProgress
|
||||||
bg *canvas.Rectangle
|
bg *canvas.Rectangle
|
||||||
fill *canvas.Rectangle
|
fill *canvas.Rectangle
|
||||||
stripes *canvas.Raster
|
stripes *canvas.Raster
|
||||||
|
|
@ -234,7 +236,7 @@ func buildJobItem(
|
||||||
descLabel.Wrapping = fyne.TextWrapWord
|
descLabel.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
// Progress bar (for running jobs)
|
// Progress bar (for running jobs)
|
||||||
progress := newStripedProgress(moduleColor(job.Type))
|
progress := NewStripedProgress(ModuleColor(job.Type))
|
||||||
progress.SetProgress(job.Progress / 100.0)
|
progress.SetProgress(job.Progress / 100.0)
|
||||||
if job.Status == queue.JobStatusCompleted {
|
if job.Status == queue.JobStatusCompleted {
|
||||||
progress.SetProgress(1.0)
|
progress.SetProgress(1.0)
|
||||||
|
|
@ -379,7 +381,8 @@ func getStatusText(job *queue.Job) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// moduleColor maps job types to distinct colors matching the main module colors
|
// moduleColor maps job types to distinct colors matching the main module colors
|
||||||
func moduleColor(t queue.JobType) color.Color {
|
// ModuleColor returns the color for a given job type
|
||||||
|
func ModuleColor(t queue.JobType) color.Color {
|
||||||
switch t {
|
switch t {
|
||||||
case queue.JobTypeConvert:
|
case queue.JobTypeConvert:
|
||||||
return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
|
return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
|
||||||
|
|
|
||||||
89
main.go
89
main.go
|
|
@ -1390,6 +1390,7 @@ func (s *appState) showMainMenu() {
|
||||||
CreatedAt: job.CreatedAt,
|
CreatedAt: job.CreatedAt,
|
||||||
StartedAt: job.StartedAt,
|
StartedAt: job.StartedAt,
|
||||||
Error: job.Error,
|
Error: job.Error,
|
||||||
|
Progress: job.Progress / 100.0, // Convert 0-100 to 0.0-1.0
|
||||||
}
|
}
|
||||||
activeJobs = append(activeJobs, entry)
|
activeJobs = append(activeJobs, entry)
|
||||||
}
|
}
|
||||||
|
|
@ -5187,16 +5188,29 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
updateEncodingControls func()
|
updateEncodingControls func()
|
||||||
updateQualityVisibility func()
|
updateQualityVisibility func()
|
||||||
buildCommandPreview func()
|
buildCommandPreview func()
|
||||||
|
updateQualityOptions func() // Update quality dropdown based on codec
|
||||||
)
|
)
|
||||||
|
|
||||||
qualityOptions := []string{
|
// Base quality options (without lossless)
|
||||||
|
baseQualityOptions := []string{
|
||||||
"Draft (CRF 28)",
|
"Draft (CRF 28)",
|
||||||
"Standard (CRF 23)",
|
"Standard (CRF 23)",
|
||||||
"Balanced (CRF 20)",
|
"Balanced (CRF 20)",
|
||||||
"High (CRF 18)",
|
"High (CRF 18)",
|
||||||
"Near-Lossless (CRF 16)",
|
"Near-Lossless (CRF 16)",
|
||||||
"Lossless",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to check if codec supports lossless
|
||||||
|
codecSupportsLossless := func(codec string) bool {
|
||||||
|
return codec == "H.265" || codec == "AV1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current quality options (dynamic based on codec)
|
||||||
|
qualityOptions := baseQualityOptions
|
||||||
|
if codecSupportsLossless(state.convert.VideoCodec) {
|
||||||
|
qualityOptions = append(qualityOptions, "Lossless")
|
||||||
|
}
|
||||||
|
|
||||||
var syncingQuality bool
|
var syncingQuality bool
|
||||||
|
|
||||||
qualitySelectSimple = widget.NewSelect(qualityOptions, func(value string) {
|
qualitySelectSimple = widget.NewSelect(qualityOptions, func(value string) {
|
||||||
|
|
@ -5243,6 +5257,29 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
qualitySelectSimple.SetSelected(state.convert.Quality)
|
qualitySelectSimple.SetSelected(state.convert.Quality)
|
||||||
qualitySelectAdv.SetSelected(state.convert.Quality)
|
qualitySelectAdv.SetSelected(state.convert.Quality)
|
||||||
|
|
||||||
|
// Update quality options based on codec
|
||||||
|
updateQualityOptions = func() {
|
||||||
|
var newOptions []string
|
||||||
|
if codecSupportsLossless(state.convert.VideoCodec) {
|
||||||
|
// H.265 and AV1 support lossless
|
||||||
|
newOptions = append(baseQualityOptions, "Lossless")
|
||||||
|
} else {
|
||||||
|
// H.264, MPEG-2, etc. don't support lossless
|
||||||
|
newOptions = baseQualityOptions
|
||||||
|
// If currently set to Lossless, fall back to Near-Lossless
|
||||||
|
if state.convert.Quality == "Lossless" {
|
||||||
|
state.convert.Quality = "Near-Lossless (CRF 16)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qualitySelectSimple.Options = newOptions
|
||||||
|
qualitySelectAdv.Options = newOptions
|
||||||
|
qualitySelectSimple.SetSelected(state.convert.Quality)
|
||||||
|
qualitySelectAdv.SetSelected(state.convert.Quality)
|
||||||
|
qualitySelectSimple.Refresh()
|
||||||
|
qualitySelectAdv.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
outputEntry := widget.NewEntry()
|
outputEntry := widget.NewEntry()
|
||||||
outputEntry.SetText(state.convert.OutputBase)
|
outputEntry.SetText(state.convert.OutputBase)
|
||||||
var updatingOutput bool
|
var updatingOutput bool
|
||||||
|
|
@ -5528,6 +5565,9 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "MPEG-2", "Copy"}, func(value string) {
|
videoCodecSelect := widget.NewSelect([]string{"H.264", "H.265", "VP9", "AV1", "MPEG-2", "Copy"}, func(value string) {
|
||||||
state.convert.VideoCodec = value
|
state.convert.VideoCodec = value
|
||||||
logging.Debug(logging.CatUI, "video codec set to %s", value)
|
logging.Debug(logging.CatUI, "video codec set to %s", value)
|
||||||
|
if updateQualityOptions != nil {
|
||||||
|
updateQualityOptions()
|
||||||
|
}
|
||||||
if updateQualityVisibility != nil {
|
if updateQualityVisibility != nil {
|
||||||
updateQualityVisibility()
|
updateQualityVisibility()
|
||||||
}
|
}
|
||||||
|
|
@ -6009,25 +6049,40 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
||||||
updateEncodingControls = func() {
|
updateEncodingControls = func() {
|
||||||
mode := state.convert.BitrateMode
|
mode := state.convert.BitrateMode
|
||||||
isLossless := state.convert.Quality == "Lossless"
|
isLossless := state.convert.Quality == "Lossless"
|
||||||
|
supportsLossless := codecSupportsLossless(state.convert.VideoCodec)
|
||||||
|
|
||||||
hint := ""
|
hint := ""
|
||||||
|
|
||||||
if isLossless {
|
if isLossless && supportsLossless {
|
||||||
// Lossless forces CRF 0; hide bitrate/target size
|
// Lossless with H.265/AV1: Allow all bitrate modes
|
||||||
if mode != "CRF" {
|
// The lossless quality affects the encoding, but bitrate/target size still control output
|
||||||
state.convert.BitrateMode = "CRF"
|
switch mode {
|
||||||
bitrateModeSelect.SetSelected("CRF")
|
case "CRF", "":
|
||||||
mode = "CRF"
|
if crfEntry.Text != "0" {
|
||||||
|
crfEntry.SetText("0")
|
||||||
|
}
|
||||||
|
state.convert.CRF = "0"
|
||||||
|
crfEntry.Disable()
|
||||||
|
crfContainer.Show()
|
||||||
|
bitrateContainer.Hide()
|
||||||
|
targetSizeContainer.Hide()
|
||||||
|
hint = "Lossless mode with CRF 0. Perfect quality preservation for H.265/AV1."
|
||||||
|
case "CBR":
|
||||||
|
crfContainer.Hide()
|
||||||
|
bitrateContainer.Show()
|
||||||
|
targetSizeContainer.Hide()
|
||||||
|
hint = "Lossless quality with constant bitrate. May achieve smaller file size than pure lossless CRF."
|
||||||
|
case "VBR":
|
||||||
|
crfContainer.Hide()
|
||||||
|
bitrateContainer.Show()
|
||||||
|
targetSizeContainer.Hide()
|
||||||
|
hint = "Lossless quality with variable bitrate. Efficient file size while maintaining lossless quality."
|
||||||
|
case "Target Size":
|
||||||
|
crfContainer.Hide()
|
||||||
|
bitrateContainer.Hide()
|
||||||
|
targetSizeContainer.Show()
|
||||||
|
hint = "Lossless quality with target size. Calculates bitrate to achieve exact file size with best possible quality."
|
||||||
}
|
}
|
||||||
if crfEntry.Text != "0" {
|
|
||||||
crfEntry.SetText("0")
|
|
||||||
}
|
|
||||||
state.convert.CRF = "0"
|
|
||||||
crfEntry.Disable()
|
|
||||||
crfContainer.Show()
|
|
||||||
bitrateContainer.Hide()
|
|
||||||
targetSizeContainer.Hide()
|
|
||||||
hint = "Lossless forces CRF 0 for H.265/AV1; bitrate and target size are ignored."
|
|
||||||
} else {
|
} else {
|
||||||
crfEntry.Enable()
|
crfEntry.Enable()
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user