Add drag-and-drop, fix cover art encoding, extract embedded thumbnails (v0.1.0-dev9)
Drag-and-Drop on Main Menu: - Implemented position-based drop detection on main menu module tiles - Added detectModuleTileAtPosition() to calculate which tile receives the drop - Modified window drop handler to pass position and route to appropriate module - Bypasses Fyne's drop event hierarchy limitation where window-level handlers intercept drops before widgets can receive them - Only enabled tiles (currently Convert) respond to drops - Loads video and switches to module automatically Cover Art Embedding Fixes: - Fixed FFmpeg exit code 234 error when embedding cover art - Added explicit PNG codec specification for cover art streams - Snippet generation: Added `-c✌️1 png` after mapping cover art stream - Full conversion: Added `-c✌️1 png` for proper MP4 thumbnail encoding - MP4 containers require attached pictures to be PNG or MJPEG encoded Embedded Cover Art Extraction: - Added EmbeddedCoverArt field to videoSource struct - Extended ffprobe parsing to detect attached_pic disposition - Automatically extracts embedded thumbnails when loading videos - Extracted cover art displays in metadata section (168x168) - Enables round-trip workflow: generate snippet with thumbnail, load snippet and see the embedded thumbnail displayed Technical Details: - Modified handleDrop to accept position parameter - Added Index and Disposition fields to ffprobe stream parsing - Cover art streams now excluded from main video stream detection - Grid layout: 3 columns, ~302px per column, ~122px per row, starts at y=100 - Embedded thumbnails extracted to /tmp/videotools-embedded-cover-*.png 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
18a14c6020
commit
183602a302
|
|
@ -9,6 +9,7 @@ import (
|
|||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -47,29 +48,63 @@ func (m *MonoTheme) Size(name fyne.ThemeSizeName) float32 {
|
|||
// ModuleTile is a clickable tile widget for module selection
|
||||
type ModuleTile struct {
|
||||
widget.BaseWidget
|
||||
label string
|
||||
color color.Color
|
||||
onTapped func()
|
||||
label string
|
||||
color color.Color
|
||||
enabled bool
|
||||
onTapped func()
|
||||
onDropped func([]fyne.URI)
|
||||
}
|
||||
|
||||
// NewModuleTile creates a new module tile
|
||||
func NewModuleTile(label string, col color.Color, tapped func()) *ModuleTile {
|
||||
func NewModuleTile(label string, col color.Color, enabled bool, tapped func(), dropped func([]fyne.URI)) *ModuleTile {
|
||||
m := &ModuleTile{
|
||||
label: strings.ToUpper(label),
|
||||
color: col,
|
||||
onTapped: tapped,
|
||||
label: strings.ToUpper(label),
|
||||
color: col,
|
||||
enabled: enabled,
|
||||
onTapped: tapped,
|
||||
onDropped: dropped,
|
||||
}
|
||||
m.ExtendBaseWidget(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// DraggedOver implements desktop.Droppable interface
|
||||
func (m *ModuleTile) DraggedOver(pos fyne.Position) {
|
||||
logging.Debug(logging.CatUI, "DraggedOver tile=%s enabled=%v pos=%v", m.label, m.enabled, pos)
|
||||
}
|
||||
|
||||
// Dropped implements desktop.Droppable interface
|
||||
func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) {
|
||||
logging.Debug(logging.CatUI, "Dropped on tile=%s enabled=%v items=%v", m.label, m.enabled, items)
|
||||
if m.enabled && m.onDropped != nil {
|
||||
logging.Debug(logging.CatUI, "Calling onDropped callback for %s", m.label)
|
||||
m.onDropped(items)
|
||||
} else {
|
||||
logging.Debug(logging.CatUI, "Drop ignored: enabled=%v hasCallback=%v", m.enabled, m.onDropped != nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
||||
bg := canvas.NewRectangle(m.color)
|
||||
tileColor := m.color
|
||||
labelColor := TextColor
|
||||
|
||||
// Dim disabled tiles
|
||||
if !m.enabled {
|
||||
// Reduce opacity by mixing with dark background
|
||||
if c, ok := m.color.(color.NRGBA); ok {
|
||||
tileColor = color.NRGBA{R: c.R / 3, G: c.G / 3, B: c.B / 3, A: c.A}
|
||||
}
|
||||
if c, ok := TextColor.(color.NRGBA); ok {
|
||||
labelColor = color.NRGBA{R: c.R / 2, G: c.G / 2, B: c.B / 2, A: c.A}
|
||||
}
|
||||
}
|
||||
|
||||
bg := canvas.NewRectangle(tileColor)
|
||||
bg.CornerRadius = 8
|
||||
bg.StrokeColor = GridColor
|
||||
bg.StrokeWidth = 1
|
||||
|
||||
txt := canvas.NewText(m.label, TextColor)
|
||||
txt := canvas.NewText(m.label, labelColor)
|
||||
txt.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||
txt.Alignment = fyne.TextAlignCenter
|
||||
txt.TextSize = 20
|
||||
|
|
@ -82,7 +117,7 @@ func (m *ModuleTile) CreateRenderer() fyne.WidgetRenderer {
|
|||
}
|
||||
|
||||
func (m *ModuleTile) Tapped(*fyne.PointEvent) {
|
||||
if m.onTapped != nil {
|
||||
if m.enabled && m.onTapped != nil {
|
||||
m.onTapped()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ import (
|
|||
|
||||
// ModuleInfo contains information about a module for display
|
||||
type ModuleInfo struct {
|
||||
ID string
|
||||
Label string
|
||||
Color color.Color
|
||||
ID string
|
||||
Label string
|
||||
Color color.Color
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// BuildMainMenu creates the main menu view with module tiles
|
||||
func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), titleColor, queueColor, textColor color.Color) fyne.CanvasObject {
|
||||
func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDrop func(string, []fyne.URI), titleColor, queueColor, textColor color.Color) fyne.CanvasObject {
|
||||
title := canvas.NewText("VIDEOTOOLS", titleColor)
|
||||
title.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
|
||||
title.TextSize = 28
|
||||
|
|
@ -35,9 +36,17 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), titleColor,
|
|||
var tileObjects []fyne.CanvasObject
|
||||
for _, mod := range modules {
|
||||
modID := mod.ID // Capture for closure
|
||||
tileObjects = append(tileObjects, buildModuleTile(mod, func() {
|
||||
onModuleClick(modID)
|
||||
}))
|
||||
var tapFunc func()
|
||||
var dropFunc func([]fyne.URI)
|
||||
if mod.Enabled {
|
||||
tapFunc = func() {
|
||||
onModuleClick(modID)
|
||||
}
|
||||
dropFunc = func(items []fyne.URI) {
|
||||
onModuleDrop(modID, items)
|
||||
}
|
||||
}
|
||||
tileObjects = append(tileObjects, buildModuleTile(mod, tapFunc, dropFunc))
|
||||
}
|
||||
|
||||
grid := container.NewGridWithColumns(3, tileObjects...)
|
||||
|
|
@ -55,9 +64,9 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), titleColor,
|
|||
}
|
||||
|
||||
// buildModuleTile creates a single module tile
|
||||
func buildModuleTile(mod ModuleInfo, tapped func()) fyne.CanvasObject {
|
||||
logging.Debug(logging.CatUI, "building tile %s color=%v", mod.ID, mod.Color)
|
||||
return container.NewPadded(NewModuleTile(mod.Label, mod.Color, tapped))
|
||||
func buildModuleTile(mod ModuleInfo, tapped func(), dropped func([]fyne.URI)) fyne.CanvasObject {
|
||||
logging.Debug(logging.CatUI, "building tile %s color=%v enabled=%v", mod.ID, mod.Color, mod.Enabled)
|
||||
return container.NewPadded(NewModuleTile(mod.Label, mod.Color, mod.Enabled, tapped, dropped))
|
||||
}
|
||||
|
||||
// buildQueueTile creates the queue status tile
|
||||
|
|
|
|||
435
main.go
435
main.go
|
|
@ -125,9 +125,9 @@ func (c convertConfig) OutputFile() string {
|
|||
|
||||
func (c convertConfig) CoverLabel() string {
|
||||
if strings.TrimSpace(c.CoverArtPath) == "" {
|
||||
return "Cover: none"
|
||||
return "none"
|
||||
}
|
||||
return fmt.Sprintf("Cover: %s", filepath.Base(c.CoverArtPath))
|
||||
return filepath.Base(c.CoverArtPath)
|
||||
}
|
||||
|
||||
type appState struct {
|
||||
|
|
@ -276,14 +276,15 @@ func (s *appState) showMainMenu() {
|
|||
var mods []ui.ModuleInfo
|
||||
for _, m := range modulesList {
|
||||
mods = append(mods, ui.ModuleInfo{
|
||||
ID: m.ID,
|
||||
Label: m.Label,
|
||||
Color: m.Color,
|
||||
ID: m.ID,
|
||||
Label: m.Label,
|
||||
Color: m.Color,
|
||||
Enabled: m.ID == "convert", // Only convert module is functional
|
||||
})
|
||||
}
|
||||
|
||||
titleColor := utils.MustHex("#4CE870")
|
||||
menu := ui.BuildMainMenu(mods, s.showModule, titleColor, queueColor, textColor)
|
||||
menu := ui.BuildMainMenu(mods, s.showModule, s.handleModuleDrop, titleColor, queueColor, textColor)
|
||||
s.setContent(container.NewPadded(menu))
|
||||
}
|
||||
|
||||
|
|
@ -296,6 +297,36 @@ func (s *appState) showModule(id string) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
|
||||
logging.Debug(logging.CatModule, "handleModuleDrop called: moduleID=%s itemCount=%d", moduleID, len(items))
|
||||
if len(items) == 0 {
|
||||
logging.Debug(logging.CatModule, "handleModuleDrop: no items to process")
|
||||
return
|
||||
}
|
||||
// Load the first video file
|
||||
for _, uri := range items {
|
||||
logging.Debug(logging.CatModule, "handleModuleDrop: processing uri scheme=%s path=%s", uri.Scheme(), uri.Path())
|
||||
if uri.Scheme() != "file" {
|
||||
logging.Debug(logging.CatModule, "handleModuleDrop: skipping non-file URI")
|
||||
continue
|
||||
}
|
||||
path := uri.Path()
|
||||
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
|
||||
|
||||
// Load video and switch to the module
|
||||
go func() {
|
||||
logging.Debug(logging.CatModule, "loading video in goroutine")
|
||||
s.loadVideo(path)
|
||||
// After loading, switch to the module
|
||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||
logging.Debug(logging.CatModule, "showing module %s after load", moduleID)
|
||||
s.showModule(moduleID)
|
||||
}, false)
|
||||
}()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) showConvertView(file *videoSource) {
|
||||
s.stopPreview()
|
||||
s.active = "convert"
|
||||
|
|
@ -395,7 +426,7 @@ func runGUI() {
|
|||
}
|
||||
defer state.shutdown()
|
||||
w.SetOnDropped(func(pos fyne.Position, items []fyne.URI) {
|
||||
state.handleDrop(items)
|
||||
state.handleDrop(pos, items)
|
||||
})
|
||||
state.showMainMenu()
|
||||
logging.Debug(logging.CatUI, "main menu rendered with %d modules", len(modulesList))
|
||||
|
|
@ -537,6 +568,8 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
backBar := ui.TintedBar(convertColor, container.NewHBox(back, layout.NewSpacer()))
|
||||
|
||||
var updateCover func(string)
|
||||
var coverDisplay *widget.Label
|
||||
var updateMetaCover func()
|
||||
coverLabel := widget.NewLabel(state.convert.CoverLabel())
|
||||
updateCover = func(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
|
|
@ -544,10 +577,17 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
}
|
||||
state.convert.CoverArtPath = path
|
||||
coverLabel.SetText(state.convert.CoverLabel())
|
||||
if coverDisplay != nil {
|
||||
coverDisplay.SetText("Cover Art: " + state.convert.CoverLabel())
|
||||
}
|
||||
if updateMetaCover != nil {
|
||||
updateMetaCover()
|
||||
}
|
||||
}
|
||||
|
||||
videoPanel := buildVideoPane(state, fyne.NewSize(520, 300), src, updateCover)
|
||||
metaPanel := buildMetadataPanel(state, src, fyne.NewSize(520, 160))
|
||||
metaPanel, metaCoverUpdate := buildMetadataPanel(state, src, fyne.NewSize(520, 160))
|
||||
updateMetaCover = metaCoverUpdate
|
||||
|
||||
var formatLabels []string
|
||||
for _, opt := range formatOptions {
|
||||
|
|
@ -637,40 +677,44 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
state.convert.AspectHandling = value
|
||||
}
|
||||
|
||||
// Simple mode options
|
||||
// Simple mode options - minimal controls, aspect locked to Source
|
||||
simpleOptions := container.NewVBox(
|
||||
widget.NewLabelWithStyle("Output Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
formatSelect,
|
||||
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
outputEntry,
|
||||
outputHint,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Quality", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("═══ QUALITY ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
qualitySelect,
|
||||
widget.NewLabel("Aspect ratio will match source video"),
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
|
||||
// Advanced mode options
|
||||
// Cover art display on one line
|
||||
coverDisplay = widget.NewLabel("Cover Art: " + state.convert.CoverLabel())
|
||||
|
||||
// Advanced mode options - full controls with organized sections
|
||||
advancedOptions := container.NewVBox(
|
||||
widget.NewLabelWithStyle("Output Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("═══ OUTPUT ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Format", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
formatSelect,
|
||||
widget.NewLabelWithStyle("Output Name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
outputEntry,
|
||||
outputHint,
|
||||
widget.NewLabelWithStyle("Cover Art", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
coverLabel,
|
||||
coverDisplay,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Quality", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("═══ VIDEO ═══", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Quality Preset", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
qualitySelect,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Inverse Telecine", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
inverseCheck,
|
||||
inverseHint,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Output Aspect", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Aspect Ratio", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
targetAspectSelect,
|
||||
targetAspectHint,
|
||||
aspectBox,
|
||||
widget.NewLabelWithStyle("Deinterlacing", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
inverseCheck,
|
||||
inverseHint,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
|
||||
|
|
@ -690,13 +734,23 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
|
|||
tabs.OnSelected = func(item *container.TabItem) {
|
||||
if item.Text == "Simple" {
|
||||
state.convert.Mode = "Simple"
|
||||
logging.Debug(logging.CatUI, "convert mode selected: Simple")
|
||||
// Lock aspect ratio to Source in Simple mode
|
||||
state.convert.OutputAspect = "Source"
|
||||
targetAspectSelect.SetSelected("Source")
|
||||
updateAspectBoxVisibility()
|
||||
logging.Debug(logging.CatUI, "convert mode selected: Simple (aspect locked to Source)")
|
||||
} else {
|
||||
state.convert.Mode = "Advanced"
|
||||
logging.Debug(logging.CatUI, "convert mode selected: Advanced")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Simple mode starts with Source aspect
|
||||
if state.convert.Mode == "Simple" {
|
||||
state.convert.OutputAspect = "Source"
|
||||
targetAspectSelect.SetSelected("Source")
|
||||
}
|
||||
|
||||
optionsRect := canvas.NewRectangle(utils.MustHex("#13182B"))
|
||||
optionsRect.CornerRadius = 8
|
||||
optionsRect.StrokeColor = gridColor
|
||||
|
|
@ -798,7 +852,7 @@ func makeLabeledPanel(title, body string, min fyne.Size) *fyne.Container {
|
|||
}
|
||||
|
||||
|
||||
func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) fyne.CanvasObject {
|
||||
func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) (fyne.CanvasObject, func()) {
|
||||
outer := canvas.NewRectangle(utils.MustHex("#191F35"))
|
||||
outer.CornerRadius = 8
|
||||
outer.StrokeColor = gridColor
|
||||
|
|
@ -815,7 +869,7 @@ func buildMetadataPanel(state *appState, src *videoSource, min fyne.Size) fyne.C
|
|||
widget.NewLabel("Load a clip to inspect its technical details."),
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
return container.NewMax(outer, container.NewPadded(body))
|
||||
return container.NewMax(outer, container.NewPadded(body)), func() {}
|
||||
}
|
||||
|
||||
bitrate := "--"
|
||||
|
|
@ -873,14 +927,14 @@ Channels: %s`,
|
|||
}
|
||||
}
|
||||
|
||||
// Copy metadata button
|
||||
// Copy metadata button - beside header text
|
||||
copyBtn := widget.NewButton("📋", func() {
|
||||
state.window.Clipboard().SetContent(metadataText)
|
||||
dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window)
|
||||
})
|
||||
copyBtn.Importance = widget.LowImportance
|
||||
|
||||
// Clear button to remove the loaded video and reset UI.
|
||||
// Clear button to remove the loaded video and reset UI - on the right
|
||||
clearBtn := widget.NewButton("Clear Video", func() {
|
||||
if state != nil {
|
||||
state.clearVideo()
|
||||
|
|
@ -888,15 +942,47 @@ Channels: %s`,
|
|||
})
|
||||
clearBtn.Importance = widget.LowImportance
|
||||
|
||||
buttonRow := container.NewHBox(copyBtn, clearBtn)
|
||||
top = container.NewBorder(nil, nil, nil, buttonRow, header)
|
||||
headerRow := container.NewHBox(header, copyBtn)
|
||||
top = container.NewBorder(nil, nil, nil, clearBtn, headerRow)
|
||||
|
||||
// Cover art display area - 40% larger (168x168)
|
||||
coverImg := canvas.NewImageFromFile("")
|
||||
coverImg.FillMode = canvas.ImageFillContain
|
||||
coverImg.SetMinSize(fyne.NewSize(168, 168))
|
||||
|
||||
placeholderRect := canvas.NewRectangle(utils.MustHex("#0F1529"))
|
||||
placeholderRect.SetMinSize(fyne.NewSize(168, 168))
|
||||
placeholderText := widget.NewLabel("Drop cover\nart here")
|
||||
placeholderText.Alignment = fyne.TextAlignCenter
|
||||
placeholderText.TextStyle = fyne.TextStyle{Italic: true}
|
||||
placeholder := container.NewMax(placeholderRect, container.NewCenter(placeholderText))
|
||||
|
||||
// Update cover art when changed
|
||||
updateCoverDisplay := func() {
|
||||
if state.convert.CoverArtPath != "" {
|
||||
coverImg.File = state.convert.CoverArtPath
|
||||
coverImg.Refresh()
|
||||
placeholder.Hide()
|
||||
coverImg.Show()
|
||||
} else {
|
||||
coverImg.Hide()
|
||||
placeholder.Show()
|
||||
}
|
||||
}
|
||||
updateCoverDisplay()
|
||||
|
||||
coverContainer := container.NewMax(placeholder, coverImg)
|
||||
|
||||
// Layout: metadata form on left, cover art on right (bottom-aligned)
|
||||
coverColumn := container.NewVBox(layout.NewSpacer(), coverContainer)
|
||||
contentArea := container.NewBorder(nil, nil, nil, coverColumn, info)
|
||||
|
||||
body := container.NewVBox(
|
||||
top,
|
||||
widget.NewSeparator(),
|
||||
info,
|
||||
contentArea,
|
||||
)
|
||||
return container.NewMax(outer, container.NewPadded(body))
|
||||
return container.NewMax(outer, container.NewPadded(body)), updateCoverDisplay
|
||||
}
|
||||
|
||||
func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover func(string)) fyne.CanvasObject {
|
||||
|
|
@ -1603,6 +1689,21 @@ func (s *appState) showFrameManual(path string, img *canvas.Image) {
|
|||
}
|
||||
|
||||
func (s *appState) captureCoverFromCurrent() (string, error) {
|
||||
// If we have a play session active, capture the current playing frame
|
||||
if s.playSess != nil && s.playSess.img != nil && s.playSess.img.Image != nil {
|
||||
dest := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-cover-%d.png", time.Now().UnixNano()))
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := png.Encode(f, s.playSess.img.Image); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// Otherwise use the current preview frame
|
||||
if s.currentFrame == "" {
|
||||
return "", fmt.Errorf("no frame available")
|
||||
}
|
||||
|
|
@ -1629,7 +1730,7 @@ func (s *appState) importCoverImage(path string) (string, error) {
|
|||
return dest, nil
|
||||
}
|
||||
|
||||
func (s *appState) handleDrop(items []fyne.URI) {
|
||||
func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -1638,17 +1739,97 @@ func (s *appState) handleDrop(items []fyne.URI) {
|
|||
continue
|
||||
}
|
||||
path := uri.Path()
|
||||
logging.Debug(logging.CatModule, "drop received path=%s active=%s", path, s.active)
|
||||
logging.Debug(logging.CatModule, "drop received path=%s active=%s pos=%v", path, s.active, pos)
|
||||
|
||||
// If on main menu, detect which module tile was dropped on
|
||||
if s.active == "" {
|
||||
moduleID := s.detectModuleTileAtPosition(pos)
|
||||
if moduleID != "" {
|
||||
logging.Debug(logging.CatUI, "drop on main menu tile=%s", moduleID)
|
||||
s.handleModuleDrop(moduleID, items)
|
||||
return
|
||||
}
|
||||
logging.Debug(logging.CatUI, "drop on main menu but not over any module tile")
|
||||
return
|
||||
}
|
||||
|
||||
// If in a module, handle normally
|
||||
switch s.active {
|
||||
case "convert":
|
||||
go s.loadVideo(path)
|
||||
default:
|
||||
logging.Debug(logging.CatUI, "drop ignored; no module active to handle file")
|
||||
logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// detectModuleTileAtPosition calculates which module tile is at the given position
|
||||
// based on the main menu grid layout (3 columns)
|
||||
func (s *appState) detectModuleTileAtPosition(pos fyne.Position) string {
|
||||
logging.Debug(logging.CatUI, "detecting module tile at position x=%.1f y=%.1f", pos.X, pos.Y)
|
||||
|
||||
// Main menu layout:
|
||||
// - Window padding: ~6px
|
||||
// - Header (title + queue): ~70-80px height
|
||||
// - Padding: 14px
|
||||
// - Grid starts at approximately y=100
|
||||
// - Grid is 3 columns x 3 rows
|
||||
// - Each tile: 220x110 with padding
|
||||
|
||||
// Approximate grid start position
|
||||
const gridStartY = 100.0
|
||||
const gridStartX = 6.0 // Window padding
|
||||
|
||||
// Window width is 920, minus padding = 908
|
||||
// 3 columns = ~302px per column
|
||||
const columnWidth = 302.0
|
||||
|
||||
// Each row is tile height (110) + vertical padding (~12) = ~122
|
||||
const rowHeight = 122.0
|
||||
|
||||
// Calculate relative position within grid
|
||||
if pos.Y < gridStartY {
|
||||
logging.Debug(logging.CatUI, "position above grid (y=%.1f < %.1f)", pos.Y, gridStartY)
|
||||
return ""
|
||||
}
|
||||
|
||||
relX := pos.X - gridStartX
|
||||
relY := pos.Y - gridStartY
|
||||
|
||||
// Calculate column (0, 1, or 2)
|
||||
col := int(relX / columnWidth)
|
||||
if col < 0 || col > 2 {
|
||||
logging.Debug(logging.CatUI, "position outside grid columns (col=%d)", col)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate row (0, 1, or 2)
|
||||
row := int(relY / rowHeight)
|
||||
if row < 0 || row > 2 {
|
||||
logging.Debug(logging.CatUI, "position outside grid rows (row=%d)", row)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate module index in grid (row * 3 + col)
|
||||
moduleIndex := row*3 + col
|
||||
if moduleIndex >= len(modulesList) {
|
||||
logging.Debug(logging.CatUI, "module index %d out of range (total %d)", moduleIndex, len(modulesList))
|
||||
return ""
|
||||
}
|
||||
|
||||
moduleID := modulesList[moduleIndex].ID
|
||||
logging.Debug(logging.CatUI, "detected module: row=%d col=%d index=%d id=%s", row, col, moduleIndex, moduleID)
|
||||
|
||||
// Only return module ID if it's enabled (currently only "convert")
|
||||
if moduleID != "convert" {
|
||||
logging.Debug(logging.CatUI, "module %s is not enabled, ignoring drop", moduleID)
|
||||
return ""
|
||||
}
|
||||
|
||||
return moduleID
|
||||
}
|
||||
|
||||
func (s *appState) loadVideo(path string) {
|
||||
win := s.window
|
||||
if s.playSess != nil {
|
||||
|
|
@ -1676,7 +1857,13 @@ func (s *appState) loadVideo(path string) {
|
|||
s.applyInverseDefaults(src)
|
||||
base := strings.TrimSuffix(src.DisplayName, filepath.Ext(src.DisplayName))
|
||||
s.convert.OutputBase = base + "-convert"
|
||||
s.convert.CoverArtPath = ""
|
||||
// Use embedded cover art if present, otherwise clear
|
||||
if src.EmbeddedCoverArt != "" {
|
||||
s.convert.CoverArtPath = src.EmbeddedCoverArt
|
||||
logging.Debug(logging.CatFFMPEG, "using embedded cover art from video: %s", src.EmbeddedCoverArt)
|
||||
} else {
|
||||
s.convert.CoverArtPath = ""
|
||||
}
|
||||
s.convert.AspectHandling = "Auto"
|
||||
s.playerReady = false
|
||||
s.playerPos = 0
|
||||
|
|
@ -1763,6 +1950,13 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
|||
"-loglevel", "error",
|
||||
"-i", src.Path,
|
||||
}
|
||||
|
||||
// Add cover art if available
|
||||
hasCoverArt := cfg.CoverArtPath != ""
|
||||
if hasCoverArt {
|
||||
args = append(args, "-i", cfg.CoverArtPath)
|
||||
}
|
||||
|
||||
// Video filters.
|
||||
var vf []string
|
||||
if cfg.InverseTelecine {
|
||||
|
|
@ -1781,13 +1975,35 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
|
|||
crf := crfForQuality(cfg.Quality)
|
||||
if cfg.SelectedFormat.VideoCodec == "libx264" || cfg.SelectedFormat.VideoCodec == "libx265" {
|
||||
args = append(args, "-crf", crf, "-preset", "medium")
|
||||
// Force yuv420p pixel format for H.264 compatibility (especially for WMV sources)
|
||||
args = append(args, "-pix_fmt", "yuv420p")
|
||||
}
|
||||
// Audio: copy if present.
|
||||
args = append(args, "-c:a", "copy")
|
||||
// Audio: WMV files need re-encoding to AAC for MP4 compatibility
|
||||
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
|
||||
isMP4Output := strings.EqualFold(cfg.SelectedFormat.Ext, ".mp4")
|
||||
if isWMV && isMP4Output {
|
||||
// WMV audio (wmav2) cannot be copied to MP4, must re-encode to AAC
|
||||
args = append(args, "-c:a", "aac", "-b:a", "192k")
|
||||
logging.Debug(logging.CatFFMPEG, "WMV source detected, re-encoding audio to AAC for MP4 compatibility")
|
||||
} else {
|
||||
// Copy audio if present
|
||||
args = append(args, "-c:a", "copy")
|
||||
}
|
||||
// Map cover art as attached picture (must be before movflags and progress)
|
||||
if hasCoverArt {
|
||||
// Need to explicitly map streams when adding cover art
|
||||
args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v")
|
||||
// Set cover art codec to PNG (MP4 requires PNG or MJPEG for attached pics)
|
||||
args = append(args, "-c:v:1", "png")
|
||||
args = append(args, "-disposition:v:1", "attached_pic")
|
||||
logging.Debug(logging.CatFFMPEG, "convert: mapped cover art as attached picture with PNG codec")
|
||||
}
|
||||
|
||||
// Ensure quickstart for MP4/MOV outputs.
|
||||
if strings.EqualFold(cfg.SelectedFormat.Ext, ".mp4") || strings.EqualFold(cfg.SelectedFormat.Ext, ".mov") {
|
||||
args = append(args, "-movflags", "+faststart")
|
||||
}
|
||||
|
||||
// Progress feed to stdout for live updates.
|
||||
args = append(args, "-progress", "pipe:1", "-nostats")
|
||||
args = append(args, outPath)
|
||||
|
|
@ -2037,22 +2253,24 @@ func aspectFilters(target float64, mode string) []string {
|
|||
return []string{scale, "setsar=1"}
|
||||
}
|
||||
|
||||
// Blur Fill: keep source resolution, just pad to target aspect
|
||||
// Blur Fill: create blurred background then overlay original video
|
||||
if strings.EqualFold(mode, "Blur Fill") {
|
||||
// No scaling - keep original video size and just pad around it
|
||||
pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar)
|
||||
return []string{pad, "setsar=1"}
|
||||
// Complex filter chain:
|
||||
// 1. Split input into two streams
|
||||
// 2. Blur and scale one stream to fill the target canvas
|
||||
// 3. Overlay the original video centered on top
|
||||
// Output dimensions with even numbers
|
||||
outW := fmt.Sprintf("trunc(max(iw,ih*%[1]s)/2)*2", ar)
|
||||
outH := fmt.Sprintf("trunc(max(ih,iw/%[1]s)/2)*2", ar)
|
||||
|
||||
// Filter: split[bg][fg]; [bg]scale=outW:outH,boxblur=20:5[blurred]; [blurred][fg]overlay=(W-w)/2:(H-h)/2
|
||||
filterStr := fmt.Sprintf("split[bg][fg];[bg]scale=%s:%s:force_original_aspect_ratio=increase,boxblur=20:5[blurred];[blurred][fg]overlay=(W-w)/2:(H-h)/2", outW, outH)
|
||||
return []string{filterStr, "setsar=1"}
|
||||
}
|
||||
|
||||
// Letterbox/Pillarbox: fit image then add bars
|
||||
// Scale to fit inside target aspect while maintaining source aspect ratio
|
||||
// If target is wider: scale to fit height, will pad sides
|
||||
// If target is narrower: scale to fit width, will pad top/bottom
|
||||
scale := fmt.Sprintf("scale=w='trunc(if(gt(iw/ih,%[1]s),trunc(ih*%[1]s/2)*2,iw)/2)*2':h='trunc(if(gt(iw/ih,%[1]s),ih,trunc(iw/%[1]s/2)*2)/2)*2'", ar)
|
||||
|
||||
// Pad to exact target aspect with even dimensions
|
||||
// Letterbox/Pillarbox: keep source resolution, just pad to target aspect with black bars
|
||||
pad := fmt.Sprintf("pad=w='trunc(max(iw,ih*%[1]s)/2)*2':h='trunc(max(ih,iw/%[1]s)/2)*2':x='(ow-iw)/2':y='(oh-ih)/2':color=black", ar)
|
||||
return []string{scale, pad, "setsar=1"}
|
||||
return []string{pad, "setsar=1"}
|
||||
}
|
||||
|
||||
func (s *appState) generateSnippet() {
|
||||
|
|
@ -2074,22 +2292,63 @@ func (s *appState) generateSnippet() {
|
|||
"-t", "20",
|
||||
}
|
||||
|
||||
// Add cover art if available
|
||||
hasCoverArt := s.convert.CoverArtPath != ""
|
||||
logging.Debug(logging.CatFFMPEG, "snippet: CoverArtPath=%s hasCoverArt=%v", s.convert.CoverArtPath, hasCoverArt)
|
||||
if hasCoverArt {
|
||||
args = append(args, "-i", s.convert.CoverArtPath)
|
||||
logging.Debug(logging.CatFFMPEG, "snippet: added cover art input %s", s.convert.CoverArtPath)
|
||||
}
|
||||
|
||||
// Check if aspect ratio conversion is needed
|
||||
srcAspect := utils.AspectRatioFloat(src.Width, src.Height)
|
||||
targetAspect := resolveTargetAspect(s.convert.OutputAspect, src)
|
||||
|
||||
if targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01) {
|
||||
needsReencode := targetAspect > 0 && srcAspect > 0 && !utils.RatiosApproxEqual(targetAspect, srcAspect, 0.01)
|
||||
|
||||
if needsReencode {
|
||||
// Apply aspect ratio filters
|
||||
filters := aspectFilters(targetAspect, s.convert.AspectHandling)
|
||||
if len(filters) > 0 {
|
||||
filterStr := strings.Join(filters, ",")
|
||||
args = append(args, "-vf", filterStr)
|
||||
}
|
||||
// Re-encode with H.264
|
||||
args = append(args, "-c:v", "libx264", "-crf", "23", "-c:a", "copy")
|
||||
}
|
||||
|
||||
// Map streams (including cover art if present)
|
||||
if hasCoverArt {
|
||||
args = append(args, "-map", "0:v", "-map", "0:a?", "-map", "1:v")
|
||||
logging.Debug(logging.CatFFMPEG, "snippet: mapped video, audio, and cover art")
|
||||
}
|
||||
|
||||
// Set video codec
|
||||
if needsReencode {
|
||||
args = append(args, "-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p")
|
||||
} else if hasCoverArt {
|
||||
args = append(args, "-c:v:0", "copy")
|
||||
} else {
|
||||
// No conversion needed, just copy
|
||||
args = append(args, "-c", "copy")
|
||||
args = append(args, "-c:v", "copy")
|
||||
}
|
||||
|
||||
// Set cover art codec (must be PNG or MJPEG for MP4)
|
||||
if hasCoverArt {
|
||||
args = append(args, "-c:v:1", "png")
|
||||
logging.Debug(logging.CatFFMPEG, "snippet: set cover art codec to PNG")
|
||||
}
|
||||
|
||||
// Set audio codec
|
||||
isWMV := strings.HasSuffix(strings.ToLower(src.Path), ".wmv")
|
||||
if needsReencode && isWMV {
|
||||
args = append(args, "-c:a", "aac", "-b:a", "192k")
|
||||
logging.Debug(logging.CatFFMPEG, "WMV snippet: re-encoding audio to AAC for MP4 compatibility")
|
||||
} else {
|
||||
args = append(args, "-c:a", "copy")
|
||||
}
|
||||
|
||||
// Mark cover art as attached picture
|
||||
if hasCoverArt {
|
||||
args = append(args, "-disposition:v:1", "attached_pic")
|
||||
logging.Debug(logging.CatFFMPEG, "snippet: set cover art disposition")
|
||||
}
|
||||
|
||||
args = append(args, outPath)
|
||||
|
|
@ -2139,21 +2398,22 @@ func capturePreviewFrames(path string, duration float64) ([]string, error) {
|
|||
|
||||
|
||||
type videoSource struct {
|
||||
Path string
|
||||
DisplayName string
|
||||
Format string
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Bitrate int
|
||||
FrameRate float64
|
||||
PixelFormat string
|
||||
AudioRate int
|
||||
Channels int
|
||||
FieldOrder string
|
||||
PreviewFrames []string
|
||||
Path string
|
||||
DisplayName string
|
||||
Format string
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Bitrate int
|
||||
FrameRate float64
|
||||
PixelFormat string
|
||||
AudioRate int
|
||||
Channels int
|
||||
FieldOrder string
|
||||
PreviewFrames []string
|
||||
EmbeddedCoverArt string // Path to extracted embedded cover art, if any
|
||||
}
|
||||
|
||||
func (v *videoSource) DurationString() string {
|
||||
|
|
@ -2232,6 +2492,7 @@ func probeVideo(path string) (*videoSource, error) {
|
|||
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"`
|
||||
|
|
@ -2243,6 +2504,9 @@ func probeVideo(path string) (*videoSource, error) {
|
|||
Channels int `json:"channels"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
FieldOrder string `json:"field_order"`
|
||||
Disposition struct {
|
||||
AttachedPic int `json:"attached_pic"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
|
|
@ -2262,10 +2526,22 @@ func probeVideo(path string) (*videoSource, error) {
|
|||
src.Duration = val
|
||||
}
|
||||
}
|
||||
// Track if we've found the main video stream (not cover art)
|
||||
foundMainVideo := false
|
||||
var coverArtStreamIndex int = -1
|
||||
|
||||
for _, stream := range result.Streams {
|
||||
switch stream.CodecType {
|
||||
case "video":
|
||||
if src.VideoCodec == "" {
|
||||
// Check if this is an attached picture (cover art)
|
||||
if stream.Disposition.AttachedPic == 1 {
|
||||
coverArtStreamIndex = stream.Index
|
||||
logging.Debug(logging.CatFFMPEG, "found embedded cover art at stream %d", stream.Index)
|
||||
continue
|
||||
}
|
||||
// Only use the first non-cover-art video stream
|
||||
if !foundMainVideo {
|
||||
foundMainVideo = true
|
||||
src.VideoCodec = stream.CodecName
|
||||
src.FieldOrder = stream.FieldOrder
|
||||
if stream.Width > 0 {
|
||||
|
|
@ -2301,6 +2577,25 @@ func probeVideo(path string) (*videoSource, error) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract embedded cover art if present
|
||||
if coverArtStreamIndex >= 0 {
|
||||
coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano()))
|
||||
extractCmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-i", path,
|
||||
"-map", fmt.Sprintf("0:%d", coverArtStreamIndex),
|
||||
"-frames:v", "1",
|
||||
"-y",
|
||||
coverPath,
|
||||
)
|
||||
if err := extractCmd.Run(); err != nil {
|
||||
logging.Debug(logging.CatFFMPEG, "failed to extract embedded cover art: %v", err)
|
||||
} else {
|
||||
src.EmbeddedCoverArt = coverPath
|
||||
logging.Debug(logging.CatFFMPEG, "extracted embedded cover art to %s", coverPath)
|
||||
}
|
||||
}
|
||||
|
||||
return src, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user