Compare commits
4 Commits
62802aa79e
...
960def5730
| Author | SHA1 | Date | |
|---|---|---|---|
| 960def5730 | |||
| 1b1657bc21 | |||
| 9315a793ba | |||
| 588fc586a1 |
508
author_module.go
508
author_module.go
|
|
@ -7,11 +7,13 @@ import (
|
|||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
|
|
@ -20,6 +22,8 @@ import (
|
|||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/queue"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||
)
|
||||
|
|
@ -555,11 +559,35 @@ func buildAuthorDiscTab(state *appState) fyne.CanvasObject {
|
|||
summaryLabel.Wrapping = fyne.TextWrapWord
|
||||
state.authorSummaryLabel = summaryLabel
|
||||
|
||||
statusLabel := widget.NewLabel("Ready")
|
||||
statusLabel.Wrapping = fyne.TextWrapWord
|
||||
state.authorStatusLabel = statusLabel
|
||||
|
||||
progressBar := widget.NewProgressBar()
|
||||
progressBar.SetValue(state.authorProgress / 100.0)
|
||||
state.authorProgressBar = progressBar
|
||||
|
||||
logEntry := widget.NewMultiLineEntry()
|
||||
logEntry.Wrapping = fyne.TextWrapOff
|
||||
logEntry.Disable()
|
||||
logEntry.SetText(state.authorLogText)
|
||||
state.authorLogEntry = logEntry
|
||||
logScroll := container.NewVScroll(logEntry)
|
||||
logScroll.SetMinSize(fyne.NewSize(0, 200))
|
||||
state.authorLogScroll = logScroll
|
||||
|
||||
controls := container.NewVBox(
|
||||
widget.NewLabel("Generate DVD/ISO:"),
|
||||
widget.NewSeparator(),
|
||||
summaryLabel,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabel("Status:"),
|
||||
statusLabel,
|
||||
progressBar,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabel("Authoring Log:"),
|
||||
logScroll,
|
||||
widget.NewSeparator(),
|
||||
generateBtn,
|
||||
)
|
||||
|
||||
|
|
@ -925,6 +953,51 @@ func concatDVDMpg(inputs []string, output string) error {
|
|||
return runCommand(platformConfig.FFmpegPath, args)
|
||||
}
|
||||
|
||||
func (s *appState) resetAuthorLog() {
|
||||
s.authorLogText = ""
|
||||
if s.authorLogEntry != nil {
|
||||
s.authorLogEntry.SetText("")
|
||||
}
|
||||
if s.authorLogScroll != nil {
|
||||
s.authorLogScroll.ScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) appendAuthorLog(line string) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
s.authorLogText += line + "\n"
|
||||
if s.authorLogEntry != nil {
|
||||
s.authorLogEntry.SetText(s.authorLogText)
|
||||
}
|
||||
if s.authorLogScroll != nil {
|
||||
s.authorLogScroll.ScrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) setAuthorStatus(text string) {
|
||||
if text == "" {
|
||||
text = "Ready"
|
||||
}
|
||||
if s.authorStatusLabel != nil {
|
||||
s.authorStatusLabel.SetText(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) setAuthorProgress(percent float64) {
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
s.authorProgress = percent
|
||||
if s.authorProgressBar != nil {
|
||||
s.authorProgressBar.SetValue(percent / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) startAuthorGeneration() {
|
||||
paths, primary, err := s.authorSourcePaths()
|
||||
if err != nil {
|
||||
|
|
@ -940,15 +1013,27 @@ func (s *appState) startAuthorGeneration() {
|
|||
}
|
||||
|
||||
warnings := authorWarnings(s)
|
||||
uiCall := func(fn func()) {
|
||||
app := fyne.CurrentApp()
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(fn, false)
|
||||
return
|
||||
}
|
||||
fn()
|
||||
}
|
||||
continuePrompt := func() {
|
||||
s.promptAuthorOutput(paths, region, aspect, title)
|
||||
uiCall(func() {
|
||||
s.promptAuthorOutput(paths, region, aspect, title)
|
||||
})
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
dialog.ShowConfirm("Authoring Notes", strings.Join(warnings, "\n")+"\n\nContinue?", func(ok bool) {
|
||||
if ok {
|
||||
continuePrompt()
|
||||
}
|
||||
}, s.window)
|
||||
uiCall(func() {
|
||||
dialog.ShowConfirm("Authoring Notes", strings.Join(warnings, "\n")+"\n\nContinue?", func(ok bool) {
|
||||
if ok {
|
||||
continuePrompt()
|
||||
}
|
||||
}, s.window)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1083,34 +1168,78 @@ func authorOutputFolderName(title string, paths []string) string {
|
|||
}
|
||||
|
||||
func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) {
|
||||
if err := ensureAuthorDependencies(makeISO); err != nil {
|
||||
if err := s.addAuthorToQueue(paths, region, aspect, title, outputPath, makeISO, true); err != nil {
|
||||
dialog.ShowError(err, s.window)
|
||||
return
|
||||
}
|
||||
|
||||
progress := dialog.NewProgressInfinite("Authoring DVD", "Encoding sources...", s.window)
|
||||
progress.Show()
|
||||
|
||||
go func() {
|
||||
err := s.runAuthoringPipeline(paths, region, aspect, title, outputPath, makeISO)
|
||||
message := "DVD authoring complete."
|
||||
if makeISO {
|
||||
message = fmt.Sprintf("ISO image created:\n%s", outputPath)
|
||||
} else {
|
||||
message = fmt.Sprintf("DVD folders created:\n%s", outputPath)
|
||||
}
|
||||
runOnUI(func() {
|
||||
progress.Hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(err, s.window)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("Authoring Complete", message, s.window)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, outputPath string, makeISO bool) error {
|
||||
func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outputPath string, makeISO bool, startNow bool) error {
|
||||
if s.jobQueue == nil {
|
||||
return fmt.Errorf("queue not initialized")
|
||||
}
|
||||
|
||||
clips := make([]map[string]interface{}, 0, len(s.authorClips))
|
||||
for _, clip := range s.authorClips {
|
||||
clips = append(clips, map[string]interface{}{
|
||||
"path": clip.Path,
|
||||
"displayName": clip.DisplayName,
|
||||
"duration": clip.Duration,
|
||||
"chapterTitle": clip.ChapterTitle,
|
||||
})
|
||||
}
|
||||
chapters := make([]map[string]interface{}, 0, len(s.authorChapters))
|
||||
for _, ch := range s.authorChapters {
|
||||
chapters = append(chapters, map[string]interface{}{
|
||||
"timestamp": ch.Timestamp,
|
||||
"title": ch.Title,
|
||||
"auto": ch.Auto,
|
||||
})
|
||||
}
|
||||
|
||||
config := map[string]interface{}{
|
||||
"paths": paths,
|
||||
"region": region,
|
||||
"aspect": aspect,
|
||||
"title": title,
|
||||
"outputPath": outputPath,
|
||||
"makeISO": makeISO,
|
||||
"treatAsChapters": s.authorTreatAsChapters,
|
||||
"clips": clips,
|
||||
"chapters": chapters,
|
||||
"discSize": s.authorDiscSize,
|
||||
"outputType": s.authorOutputType,
|
||||
"authorTitle": s.authorTitle,
|
||||
"authorRegion": s.authorRegion,
|
||||
"authorAspect": s.authorAspectRatio,
|
||||
"chapterSource": s.authorChapterSource,
|
||||
"subtitleTracks": append([]string{}, s.authorSubtitles...),
|
||||
"additionalAudios": append([]string{}, s.authorAudioTracks...),
|
||||
}
|
||||
|
||||
titleLabel := title
|
||||
if strings.TrimSpace(titleLabel) == "" {
|
||||
titleLabel = "DVD"
|
||||
}
|
||||
job := &queue.Job{
|
||||
Type: queue.JobTypeAuthor,
|
||||
Title: fmt.Sprintf("Author DVD: %s", titleLabel),
|
||||
Description: fmt.Sprintf("Output: %s", utils.ShortenMiddle(filepath.Base(outputPath), 40)),
|
||||
InputFile: paths[0],
|
||||
OutputFile: outputPath,
|
||||
Config: config,
|
||||
}
|
||||
|
||||
s.resetAuthorLog()
|
||||
s.setAuthorStatus("Queued authoring job...")
|
||||
s.setAuthorProgress(0)
|
||||
s.jobQueue.Add(job)
|
||||
if startNow && !s.jobQueue.IsRunning() {
|
||||
s.jobQueue.Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appState) runAuthoringPipeline(ctx context.Context, paths []string, region, aspect, title, outputPath string, makeISO bool, clips []authorClip, chapters []authorChapter, treatAsChapters bool, logFn func(string), progressFn func(float64)) error {
|
||||
workDir, err := os.MkdirTemp(utils.TempDir(), "videotools-author-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
|
|
@ -1137,25 +1266,56 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o
|
|||
return err
|
||||
}
|
||||
|
||||
mpgPaths, err := encodeAuthorSources(paths, region, aspect, workDir)
|
||||
if err != nil {
|
||||
return err
|
||||
totalSteps := len(paths) + 2
|
||||
if makeISO {
|
||||
totalSteps++
|
||||
}
|
||||
step := 0
|
||||
advance := func(message string) {
|
||||
step++
|
||||
if logFn != nil && message != "" {
|
||||
logFn(message)
|
||||
}
|
||||
if progressFn != nil && totalSteps > 0 {
|
||||
progressFn(float64(step) / float64(totalSteps) * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
chapters := s.authorChapters
|
||||
if len(chapters) == 0 && s.authorTreatAsChapters && len(s.authorClips) > 1 {
|
||||
chapters = chaptersFromClips(s.authorClips)
|
||||
s.authorChapterSource = "clips"
|
||||
var mpgPaths []string
|
||||
for i, path := range paths {
|
||||
if logFn != nil {
|
||||
logFn(fmt.Sprintf("Encoding %d/%d: %s", i+1, len(paths), filepath.Base(path)))
|
||||
}
|
||||
outPath := filepath.Join(workDir, fmt.Sprintf("title_%02d.mpg", i+1))
|
||||
src, err := probeVideo(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive())
|
||||
if logFn != nil {
|
||||
logFn(fmt.Sprintf(">> ffmpeg %s", strings.Join(args, " ")))
|
||||
}
|
||||
if err := runCommandWithLogger(ctx, platformConfig.FFmpegPath, args, logFn); err != nil {
|
||||
return err
|
||||
}
|
||||
mpgPaths = append(mpgPaths, outPath)
|
||||
advance("")
|
||||
}
|
||||
|
||||
if len(chapters) == 0 && treatAsChapters && len(clips) > 1 {
|
||||
chapters = chaptersFromClips(clips)
|
||||
}
|
||||
if len(chapters) == 0 && len(mpgPaths) == 1 {
|
||||
if embed, err := extractChaptersFromFile(paths[0]); err == nil && len(embed) > 0 {
|
||||
chapters = embed
|
||||
s.authorChapterSource = "embedded"
|
||||
}
|
||||
}
|
||||
|
||||
if s.authorTreatAsChapters && len(mpgPaths) > 1 {
|
||||
if treatAsChapters && len(mpgPaths) > 1 {
|
||||
concatPath := filepath.Join(workDir, "titles_joined.mpg")
|
||||
if logFn != nil {
|
||||
logFn("Concatenating chapters into a single title...")
|
||||
}
|
||||
if err := concatDVDMpg(mpgPaths, concatPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1171,13 +1331,23 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o
|
|||
return err
|
||||
}
|
||||
|
||||
if err := runCommand("dvdauthor", []string{"-o", discRoot, "-x", xmlPath}); err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Authoring DVD structure...")
|
||||
logFn(fmt.Sprintf(">> dvdauthor -o %s -x %s", discRoot, xmlPath))
|
||||
}
|
||||
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-x", xmlPath}, logFn); err != nil {
|
||||
return err
|
||||
}
|
||||
advance("")
|
||||
|
||||
if err := runCommand("dvdauthor", []string{"-o", discRoot, "-T"}); err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Building DVD tables...")
|
||||
logFn(fmt.Sprintf(">> dvdauthor -o %s -T", discRoot))
|
||||
}
|
||||
if err := runCommandWithLogger(ctx, "dvdauthor", []string{"-o", discRoot, "-T"}, logFn); err != nil {
|
||||
return err
|
||||
}
|
||||
advance("")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create AUDIO_TS: %w", err)
|
||||
|
|
@ -1188,14 +1358,180 @@ func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, o
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runCommand(tool, args); err != nil {
|
||||
if logFn != nil {
|
||||
logFn("Creating ISO image...")
|
||||
logFn(fmt.Sprintf(">> %s %s", tool, strings.Join(args, " ")))
|
||||
}
|
||||
if err := runCommandWithLogger(ctx, tool, args, logFn); err != nil {
|
||||
return err
|
||||
}
|
||||
advance("")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appState) executeAuthorJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
|
||||
cfg := job.Config
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("author job config missing")
|
||||
}
|
||||
rawPaths, _ := cfg["paths"].([]interface{})
|
||||
var paths []string
|
||||
for _, p := range rawPaths {
|
||||
paths = append(paths, toString(p))
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
if path, ok := cfg["paths"].([]string); ok {
|
||||
paths = append(paths, path...)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
if input, ok := cfg["inputPath"].(string); ok && input != "" {
|
||||
paths = append(paths, input)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("no input paths for author job")
|
||||
}
|
||||
|
||||
region := toString(cfg["region"])
|
||||
aspect := toString(cfg["aspect"])
|
||||
title := toString(cfg["title"])
|
||||
outputPath := toString(cfg["outputPath"])
|
||||
makeISO, _ := cfg["makeISO"].(bool)
|
||||
treatAsChapters, _ := cfg["treatAsChapters"].(bool)
|
||||
|
||||
if err := ensureAuthorDependencies(makeISO); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var clips []authorClip
|
||||
if rawClips, ok := cfg["clips"].([]interface{}); ok {
|
||||
for _, rc := range rawClips {
|
||||
if m, ok := rc.(map[string]interface{}); ok {
|
||||
clips = append(clips, authorClip{
|
||||
Path: toString(m["path"]),
|
||||
DisplayName: toString(m["displayName"]),
|
||||
Duration: toFloat(m["duration"]),
|
||||
ChapterTitle: toString(m["chapterTitle"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chapters []authorChapter
|
||||
if rawChapters, ok := cfg["chapters"].([]interface{}); ok {
|
||||
for _, rc := range rawChapters {
|
||||
if m, ok := rc.(map[string]interface{}); ok {
|
||||
chapters = append(chapters, authorChapter{
|
||||
Timestamp: toFloat(m["timestamp"]),
|
||||
Title: toString(m["title"]),
|
||||
Auto: toBool(m["auto"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logFile, logPath, logErr := createAuthorLog(paths, outputPath, makeISO, region, aspect, title)
|
||||
if logErr != nil {
|
||||
logging.Debug(logging.CatSystem, "author log open failed: %v", logErr)
|
||||
} else {
|
||||
job.LogPath = logPath
|
||||
defer logFile.Close()
|
||||
}
|
||||
|
||||
appendLog := func(line string) {
|
||||
if logFile != nil {
|
||||
fmt.Fprintln(logFile, line)
|
||||
}
|
||||
app := fyne.CurrentApp()
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(func() {
|
||||
s.appendAuthorLog(line)
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress := func(percent float64) {
|
||||
progressCallback(percent)
|
||||
app := fyne.CurrentApp()
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(func() {
|
||||
s.setAuthorProgress(percent)
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
|
||||
appendLog(fmt.Sprintf("Authoring started: %s", time.Now().Format(time.RFC3339)))
|
||||
appendLog(fmt.Sprintf("Inputs: %s", strings.Join(paths, ", ")))
|
||||
appendLog(fmt.Sprintf("Output: %s", outputPath))
|
||||
if makeISO {
|
||||
appendLog("Output mode: ISO")
|
||||
} else {
|
||||
appendLog("Output mode: VIDEO_TS")
|
||||
}
|
||||
|
||||
app := fyne.CurrentApp()
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(func() {
|
||||
s.setAuthorStatus("Authoring in progress...")
|
||||
}, false)
|
||||
}
|
||||
|
||||
err := s.runAuthoringPipeline(ctx, paths, region, aspect, title, outputPath, makeISO, clips, chapters, treatAsChapters, appendLog, updateProgress)
|
||||
if err != nil {
|
||||
friendly := authorFriendlyError(err)
|
||||
appendLog("ERROR: " + friendly)
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(func() {
|
||||
s.setAuthorStatus(friendly)
|
||||
}, false)
|
||||
}
|
||||
return fmt.Errorf("%s\nSee Authoring Log for details.", friendly)
|
||||
}
|
||||
|
||||
if app != nil && app.Driver() != nil {
|
||||
app.Driver().DoFromGoroutine(func() {
|
||||
s.setAuthorStatus("Authoring complete")
|
||||
s.setAuthorProgress(100)
|
||||
}, false)
|
||||
}
|
||||
appendLog("Authoring completed successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorFriendlyError(err error) string {
|
||||
if err == nil {
|
||||
return "Authoring failed"
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "disk quota exceeded"),
|
||||
strings.Contains(lower, "no space left"),
|
||||
strings.Contains(lower, "not enough space"):
|
||||
return "Not enough disk space for authoring output."
|
||||
case strings.Contains(lower, "output folder must be empty"):
|
||||
return "Output folder must be empty before authoring."
|
||||
case strings.Contains(lower, "dvdauthor not found"):
|
||||
return "dvdauthor not found. Install DVD authoring tools."
|
||||
case strings.Contains(lower, "mkisofs"),
|
||||
strings.Contains(lower, "genisoimage"),
|
||||
strings.Contains(lower, "xorriso"):
|
||||
return "ISO tool not found. Install mkisofs/genisoimage/xorriso."
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
return "Permission denied writing to output folder."
|
||||
case strings.Contains(lower, "ffmpeg"):
|
||||
return "FFmpeg failed during DVD encoding."
|
||||
default:
|
||||
if len(msg) > 140 {
|
||||
return "Authoring failed. See Authoring Log for details."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func prepareDiscRoot(path string) error {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
|
|
@ -1338,6 +1674,94 @@ func ensureAuthorDependencies(makeISO bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createAuthorLog(inputs []string, outputPath string, makeISO bool, region, aspect, title string) (*os.File, string, error) {
|
||||
base := strings.TrimSuffix(filepath.Base(outputPath), filepath.Ext(outputPath))
|
||||
if base == "" {
|
||||
base = "author"
|
||||
}
|
||||
logPath := filepath.Join(getLogsDir(), base+"-author"+conversionLogSuffix)
|
||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||||
return nil, logPath, fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, logPath, err
|
||||
}
|
||||
mode := "VIDEO_TS"
|
||||
if makeISO {
|
||||
mode = "ISO"
|
||||
}
|
||||
header := fmt.Sprintf(`VideoTools Authoring Log
|
||||
Started: %s
|
||||
Inputs: %s
|
||||
Output: %s
|
||||
Mode: %s
|
||||
Region: %s
|
||||
Aspect: %s
|
||||
Title: %s
|
||||
|
||||
`, time.Now().Format(time.RFC3339), strings.Join(inputs, ", "), outputPath, mode, region, aspect, title)
|
||||
if _, err := f.WriteString(header); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, logPath, err
|
||||
}
|
||||
return f, logPath, nil
|
||||
}
|
||||
|
||||
func runCommandWithLogger(ctx context.Context, name string, args []string, logFn func(string)) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
utils.ApplyNoWindow(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s stdout: %w", name, err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s stderr: %w", name, err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s start: %w", name, err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stream := func(r io.Reader) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
if logFn != nil {
|
||||
logFn(scanner.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
wg.Add(2)
|
||||
go stream(stdout)
|
||||
go stream(stderr)
|
||||
|
||||
err = cmd.Wait()
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s failed: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBool(v interface{}) bool {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case string:
|
||||
return strings.EqualFold(val, "true")
|
||||
case float64:
|
||||
return val != 0
|
||||
case int:
|
||||
return val != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ensureExecutable(path, label string) error {
|
||||
if filepath.IsAbs(path) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
|
|
|
|||
292
inspect_module.go
Normal file
292
inspect_module.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/interlace"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/ui"
|
||||
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
||||
)
|
||||
|
||||
func (s *appState) showInspectView() {
|
||||
s.stopPreview()
|
||||
s.lastModule = s.active
|
||||
s.active = "inspect"
|
||||
s.setContent(buildInspectView(s))
|
||||
}
|
||||
|
||||
// buildInspectView creates the UI for inspecting a single video with player
|
||||
func buildInspectView(state *appState) fyne.CanvasObject {
|
||||
inspectColor := moduleColor("inspect")
|
||||
|
||||
// Back button
|
||||
backBtn := widget.NewButton("< INSPECT", func() {
|
||||
state.showMainMenu()
|
||||
})
|
||||
backBtn.Importance = widget.LowImportance
|
||||
|
||||
// Top bar with module color
|
||||
queueBtn := widget.NewButton("View Queue", func() {
|
||||
state.showQueue()
|
||||
})
|
||||
state.queueBtn = queueBtn
|
||||
state.updateQueueButtonLabel()
|
||||
topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn))
|
||||
bottomBar := moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||
|
||||
// Instructions
|
||||
instructions := widget.NewLabel("Load a video to inspect its properties and preview playback. Drag a video here or use the button below.")
|
||||
instructions.Wrapping = fyne.TextWrapWord
|
||||
instructions.Alignment = fyne.TextAlignCenter
|
||||
|
||||
// Clear button
|
||||
clearBtn := widget.NewButton("Clear", func() {
|
||||
state.inspectFile = nil
|
||||
state.showInspectView()
|
||||
})
|
||||
clearBtn.Importance = widget.LowImportance
|
||||
|
||||
instructionsRow := container.NewBorder(nil, nil, nil, nil, instructions)
|
||||
|
||||
// File label
|
||||
fileLabel := widget.NewLabel("No file loaded")
|
||||
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||
|
||||
// Metadata text
|
||||
metadataText := widget.NewLabel("No file loaded")
|
||||
metadataText.Wrapping = fyne.TextWrapWord
|
||||
|
||||
// Metadata scroll
|
||||
metadataScroll := container.NewScroll(metadataText)
|
||||
metadataScroll.SetMinSize(fyne.NewSize(400, 200))
|
||||
|
||||
// Helper function to format metadata
|
||||
formatMetadata := func(src *videoSource) string {
|
||||
fileSize := "Unknown"
|
||||
if fi, err := os.Stat(src.Path); err == nil {
|
||||
fileSize = utils.FormatBytes(fi.Size())
|
||||
}
|
||||
|
||||
metadata := fmt.Sprintf(
|
||||
"━━━ FILE INFO ━━━\n"+
|
||||
"Path: %s\n"+
|
||||
"File Size: %s\n"+
|
||||
"Format Family: %s\n"+
|
||||
"\n━━━ VIDEO ━━━\n"+
|
||||
"Codec: %s\n"+
|
||||
"Resolution: %dx%d\n"+
|
||||
"Aspect Ratio: %s\n"+
|
||||
"Frame Rate: %.2f fps\n"+
|
||||
"Bitrate: %s\n"+
|
||||
"Pixel Format: %s\n"+
|
||||
"Color Space: %s\n"+
|
||||
"Color Range: %s\n"+
|
||||
"Field Order: %s\n"+
|
||||
"GOP Size: %d\n"+
|
||||
"\n━━━ AUDIO ━━━\n"+
|
||||
"Codec: %s\n"+
|
||||
"Bitrate: %s\n"+
|
||||
"Sample Rate: %d Hz\n"+
|
||||
"Channels: %d\n"+
|
||||
"\n━━━ OTHER ━━━\n"+
|
||||
"Duration: %s\n"+
|
||||
"SAR (Pixel Aspect): %s\n"+
|
||||
"Chapters: %v\n"+
|
||||
"Metadata: %v",
|
||||
filepath.Base(src.Path),
|
||||
fileSize,
|
||||
src.Format,
|
||||
src.VideoCodec,
|
||||
src.Width, src.Height,
|
||||
src.AspectRatioString(),
|
||||
src.FrameRate,
|
||||
formatBitrateFull(src.Bitrate),
|
||||
src.PixelFormat,
|
||||
src.ColorSpace,
|
||||
src.ColorRange,
|
||||
src.FieldOrder,
|
||||
src.GOPSize,
|
||||
src.AudioCodec,
|
||||
formatBitrateFull(src.AudioBitrate),
|
||||
src.AudioRate,
|
||||
src.Channels,
|
||||
src.DurationString(),
|
||||
src.SampleAspectRatio,
|
||||
src.HasChapters,
|
||||
src.HasMetadata,
|
||||
)
|
||||
|
||||
// Add interlacing detection results if available
|
||||
if state.inspectInterlaceAnalyzing {
|
||||
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||
metadata += "Analyzing... (first 500 frames)"
|
||||
} else if state.inspectInterlaceResult != nil {
|
||||
result := state.inspectInterlaceResult
|
||||
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||
metadata += fmt.Sprintf("Status: %s\n", result.Status)
|
||||
metadata += fmt.Sprintf("Interlaced Frames: %.1f%%\n", result.InterlacedPercent)
|
||||
metadata += fmt.Sprintf("Field Order: %s\n", result.FieldOrder)
|
||||
metadata += fmt.Sprintf("Confidence: %s\n", result.Confidence)
|
||||
metadata += fmt.Sprintf("Recommendation: %s\n", result.Recommendation)
|
||||
metadata += fmt.Sprintf("\nFrame Counts:\n")
|
||||
metadata += fmt.Sprintf(" Progressive: %d\n", result.Progressive)
|
||||
metadata += fmt.Sprintf(" Top Field First: %d\n", result.TFF)
|
||||
metadata += fmt.Sprintf(" Bottom Field First: %d\n", result.BFF)
|
||||
metadata += fmt.Sprintf(" Undetermined: %d\n", result.Undetermined)
|
||||
metadata += fmt.Sprintf(" Total Analyzed: %d", result.TotalFrames)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Video player container
|
||||
var videoContainer fyne.CanvasObject = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||
|
||||
// Update display function
|
||||
updateDisplay := func() {
|
||||
if state.inspectFile != nil {
|
||||
filename := filepath.Base(state.inspectFile.Path)
|
||||
// Truncate if too long
|
||||
if len(filename) > 50 {
|
||||
ext := filepath.Ext(filename)
|
||||
nameWithoutExt := strings.TrimSuffix(filename, ext)
|
||||
if len(ext) > 10 {
|
||||
filename = filename[:47] + "..."
|
||||
} else {
|
||||
availableLen := 47 - len(ext)
|
||||
if availableLen < 1 {
|
||||
filename = filename[:47] + "..."
|
||||
} else {
|
||||
filename = nameWithoutExt[:availableLen] + "..." + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
fileLabel.SetText(fmt.Sprintf("File: %s", filename))
|
||||
metadataText.SetText(formatMetadata(state.inspectFile))
|
||||
|
||||
// Build video player
|
||||
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.inspectFile, nil)
|
||||
} else {
|
||||
fileLabel.SetText("No file loaded")
|
||||
metadataText.SetText("No file loaded")
|
||||
videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize display
|
||||
updateDisplay()
|
||||
|
||||
// Load button
|
||||
loadBtn := widget.NewButton("Load Video", func() {
|
||||
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
path := reader.URI().Path()
|
||||
reader.Close()
|
||||
|
||||
src, err := probeVideo(path)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
|
||||
return
|
||||
}
|
||||
|
||||
state.inspectFile = src
|
||||
state.inspectInterlaceResult = nil
|
||||
state.inspectInterlaceAnalyzing = true
|
||||
state.showInspectView()
|
||||
logging.Debug(logging.CatModule, "loaded inspect file: %s", path)
|
||||
|
||||
// Auto-run interlacing detection in background
|
||||
go func() {
|
||||
detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
result, err := detector.QuickAnalyze(ctx, path)
|
||||
|
||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||
state.inspectInterlaceAnalyzing = false
|
||||
if err != nil {
|
||||
logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err)
|
||||
state.inspectInterlaceResult = nil
|
||||
} else {
|
||||
state.inspectInterlaceResult = result
|
||||
logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status)
|
||||
}
|
||||
state.showInspectView() // Refresh to show results
|
||||
}, false)
|
||||
}()
|
||||
}, state.window)
|
||||
})
|
||||
|
||||
// Copy metadata button
|
||||
copyBtn := widget.NewButton("Copy Metadata", func() {
|
||||
if state.inspectFile == nil {
|
||||
return
|
||||
}
|
||||
metadata := formatMetadata(state.inspectFile)
|
||||
state.window.Clipboard().SetContent(metadata)
|
||||
dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window)
|
||||
})
|
||||
copyBtn.Importance = widget.LowImportance
|
||||
|
||||
logPath := ""
|
||||
if state.inspectFile != nil {
|
||||
base := strings.TrimSuffix(filepath.Base(state.inspectFile.Path), filepath.Ext(state.inspectFile.Path))
|
||||
p := filepath.Join(getLogsDir(), base+conversionLogSuffix)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
logPath = p
|
||||
}
|
||||
}
|
||||
viewLogBtn := widget.NewButton("View Conversion Log", func() {
|
||||
if logPath == "" {
|
||||
dialog.ShowInformation("No Log", "No conversion log found for this file.", state.window)
|
||||
return
|
||||
}
|
||||
state.openLogViewer("Conversion Log", logPath, false)
|
||||
})
|
||||
viewLogBtn.Importance = widget.LowImportance
|
||||
if logPath == "" {
|
||||
viewLogBtn.Disable()
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
actionButtons := container.NewHBox(loadBtn, copyBtn, viewLogBtn, clearBtn)
|
||||
|
||||
// Main layout: left side is video player, right side is metadata
|
||||
leftColumn := container.NewBorder(
|
||||
fileLabel,
|
||||
nil, nil, nil,
|
||||
videoContainer,
|
||||
)
|
||||
|
||||
rightColumn := container.NewBorder(
|
||||
widget.NewLabel("Metadata:"),
|
||||
nil, nil, nil,
|
||||
metadataScroll,
|
||||
)
|
||||
|
||||
// Bottom bar with module color
|
||||
bottomBar = moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||
|
||||
// Main content
|
||||
content := container.NewBorder(
|
||||
container.NewVBox(instructionsRow, actionButtons, widget.NewSeparator()),
|
||||
nil, nil, nil,
|
||||
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
||||
)
|
||||
|
||||
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const (
|
|||
JobTypeAudio JobType = "audio"
|
||||
JobTypeThumb JobType = "thumb"
|
||||
JobTypeSnippet JobType = "snippet"
|
||||
JobTypeAuthor JobType = "author"
|
||||
)
|
||||
|
||||
// JobStatus represents the current state of a job
|
||||
|
|
|
|||
279
main.go
279
main.go
|
|
@ -920,6 +920,12 @@ type appState struct {
|
|||
authorChapterSource string // embedded, scenes, clips, manual
|
||||
authorChaptersRefresh func() // Refresh hook for chapter list UI
|
||||
authorDiscSize string // "DVD5" or "DVD9"
|
||||
authorLogText string
|
||||
authorLogEntry *widget.Entry
|
||||
authorLogScroll *container.Scroll
|
||||
authorProgress float64
|
||||
authorProgressBar *widget.ProgressBar
|
||||
authorStatusLabel *widget.Label
|
||||
|
||||
// Subtitles module state
|
||||
subtitleVideoPath string
|
||||
|
|
@ -2695,13 +2701,6 @@ func (s *appState) showCompareView() {
|
|||
s.setContent(buildCompareView(s))
|
||||
}
|
||||
|
||||
func (s *appState) showInspectView() {
|
||||
s.stopPreview()
|
||||
s.lastModule = s.active
|
||||
s.active = "inspect"
|
||||
s.setContent(buildInspectView(s))
|
||||
}
|
||||
|
||||
func (s *appState) showThumbView() {
|
||||
s.stopPreview()
|
||||
s.lastModule = s.active
|
||||
|
|
@ -3216,6 +3215,8 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall
|
|||
return s.executeThumbJob(ctx, job, progressCallback)
|
||||
case queue.JobTypeSnippet:
|
||||
return s.executeSnippetJob(ctx, job, progressCallback)
|
||||
case queue.JobTypeAuthor:
|
||||
return s.executeAuthorJob(ctx, job, progressCallback)
|
||||
default:
|
||||
return fmt.Errorf("unknown job type: %s", job.Type)
|
||||
}
|
||||
|
|
@ -12560,270 +12561,6 @@ func buildCompareView(state *appState) fyne.CanvasObject {
|
|||
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
||||
// buildInspectView creates the UI for inspecting a single video with player
|
||||
func buildInspectView(state *appState) fyne.CanvasObject {
|
||||
inspectColor := moduleColor("inspect")
|
||||
|
||||
// Back button
|
||||
backBtn := widget.NewButton("< INSPECT", func() {
|
||||
state.showMainMenu()
|
||||
})
|
||||
backBtn.Importance = widget.LowImportance
|
||||
|
||||
// Top bar with module color
|
||||
queueBtn := widget.NewButton("View Queue", func() {
|
||||
state.showQueue()
|
||||
})
|
||||
state.queueBtn = queueBtn
|
||||
state.updateQueueButtonLabel()
|
||||
topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn))
|
||||
bottomBar := moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||
|
||||
// Instructions
|
||||
instructions := widget.NewLabel("Load a video to inspect its properties and preview playback. Drag a video here or use the button below.")
|
||||
instructions.Wrapping = fyne.TextWrapWord
|
||||
instructions.Alignment = fyne.TextAlignCenter
|
||||
|
||||
// Clear button
|
||||
clearBtn := widget.NewButton("Clear", func() {
|
||||
state.inspectFile = nil
|
||||
state.showInspectView()
|
||||
})
|
||||
clearBtn.Importance = widget.LowImportance
|
||||
|
||||
instructionsRow := container.NewBorder(nil, nil, nil, nil, instructions)
|
||||
|
||||
// File label
|
||||
fileLabel := widget.NewLabel("No file loaded")
|
||||
fileLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||
|
||||
// Metadata text
|
||||
metadataText := widget.NewLabel("No file loaded")
|
||||
metadataText.Wrapping = fyne.TextWrapWord
|
||||
|
||||
// Metadata scroll
|
||||
metadataScroll := container.NewScroll(metadataText)
|
||||
metadataScroll.SetMinSize(fyne.NewSize(400, 200))
|
||||
|
||||
// Helper function to format metadata
|
||||
formatMetadata := func(src *videoSource) string {
|
||||
fileSize := "Unknown"
|
||||
if fi, err := os.Stat(src.Path); err == nil {
|
||||
fileSize = utils.FormatBytes(fi.Size())
|
||||
}
|
||||
|
||||
metadata := fmt.Sprintf(
|
||||
"━━━ FILE INFO ━━━\n"+
|
||||
"Path: %s\n"+
|
||||
"File Size: %s\n"+
|
||||
"Format Family: %s\n"+
|
||||
"\n━━━ VIDEO ━━━\n"+
|
||||
"Codec: %s\n"+
|
||||
"Resolution: %dx%d\n"+
|
||||
"Aspect Ratio: %s\n"+
|
||||
"Frame Rate: %.2f fps\n"+
|
||||
"Bitrate: %s\n"+
|
||||
"Pixel Format: %s\n"+
|
||||
"Color Space: %s\n"+
|
||||
"Color Range: %s\n"+
|
||||
"Field Order: %s\n"+
|
||||
"GOP Size: %d\n"+
|
||||
"\n━━━ AUDIO ━━━\n"+
|
||||
"Codec: %s\n"+
|
||||
"Bitrate: %s\n"+
|
||||
"Sample Rate: %d Hz\n"+
|
||||
"Channels: %d\n"+
|
||||
"\n━━━ OTHER ━━━\n"+
|
||||
"Duration: %s\n"+
|
||||
"SAR (Pixel Aspect): %s\n"+
|
||||
"Chapters: %v\n"+
|
||||
"Metadata: %v",
|
||||
filepath.Base(src.Path),
|
||||
fileSize,
|
||||
src.Format,
|
||||
src.VideoCodec,
|
||||
src.Width, src.Height,
|
||||
src.AspectRatioString(),
|
||||
src.FrameRate,
|
||||
formatBitrateFull(src.Bitrate),
|
||||
src.PixelFormat,
|
||||
src.ColorSpace,
|
||||
src.ColorRange,
|
||||
src.FieldOrder,
|
||||
src.GOPSize,
|
||||
src.AudioCodec,
|
||||
formatBitrateFull(src.AudioBitrate),
|
||||
src.AudioRate,
|
||||
src.Channels,
|
||||
src.DurationString(),
|
||||
src.SampleAspectRatio,
|
||||
src.HasChapters,
|
||||
src.HasMetadata,
|
||||
)
|
||||
|
||||
// Add interlacing detection results if available
|
||||
if state.inspectInterlaceAnalyzing {
|
||||
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||
metadata += "Analyzing... (first 500 frames)"
|
||||
} else if state.inspectInterlaceResult != nil {
|
||||
result := state.inspectInterlaceResult
|
||||
metadata += "\n\n━━━ INTERLACING DETECTION ━━━\n"
|
||||
metadata += fmt.Sprintf("Status: %s\n", result.Status)
|
||||
metadata += fmt.Sprintf("Interlaced Frames: %.1f%%\n", result.InterlacedPercent)
|
||||
metadata += fmt.Sprintf("Field Order: %s\n", result.FieldOrder)
|
||||
metadata += fmt.Sprintf("Confidence: %s\n", result.Confidence)
|
||||
metadata += fmt.Sprintf("Recommendation: %s\n", result.Recommendation)
|
||||
metadata += fmt.Sprintf("\nFrame Counts:\n")
|
||||
metadata += fmt.Sprintf(" Progressive: %d\n", result.Progressive)
|
||||
metadata += fmt.Sprintf(" Top Field First: %d\n", result.TFF)
|
||||
metadata += fmt.Sprintf(" Bottom Field First: %d\n", result.BFF)
|
||||
metadata += fmt.Sprintf(" Undetermined: %d\n", result.Undetermined)
|
||||
metadata += fmt.Sprintf(" Total Analyzed: %d", result.TotalFrames)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Video player container
|
||||
var videoContainer fyne.CanvasObject = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||
|
||||
// Update display function
|
||||
updateDisplay := func() {
|
||||
if state.inspectFile != nil {
|
||||
filename := filepath.Base(state.inspectFile.Path)
|
||||
// Truncate if too long
|
||||
if len(filename) > 50 {
|
||||
ext := filepath.Ext(filename)
|
||||
nameWithoutExt := strings.TrimSuffix(filename, ext)
|
||||
if len(ext) > 10 {
|
||||
filename = filename[:47] + "..."
|
||||
} else {
|
||||
availableLen := 47 - len(ext)
|
||||
if availableLen < 1 {
|
||||
filename = filename[:47] + "..."
|
||||
} else {
|
||||
filename = nameWithoutExt[:availableLen] + "..." + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
fileLabel.SetText(fmt.Sprintf("File: %s", filename))
|
||||
metadataText.SetText(formatMetadata(state.inspectFile))
|
||||
|
||||
// Build video player
|
||||
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.inspectFile, nil)
|
||||
} else {
|
||||
fileLabel.SetText("No file loaded")
|
||||
metadataText.SetText("No file loaded")
|
||||
videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize display
|
||||
updateDisplay()
|
||||
|
||||
// Load button
|
||||
loadBtn := widget.NewButton("Load Video", func() {
|
||||
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
|
||||
if err != nil || reader == nil {
|
||||
return
|
||||
}
|
||||
path := reader.URI().Path()
|
||||
reader.Close()
|
||||
|
||||
src, err := probeVideo(path)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
|
||||
return
|
||||
}
|
||||
|
||||
state.inspectFile = src
|
||||
state.inspectInterlaceResult = nil
|
||||
state.inspectInterlaceAnalyzing = true
|
||||
state.showInspectView()
|
||||
logging.Debug(logging.CatModule, "loaded inspect file: %s", path)
|
||||
|
||||
// Auto-run interlacing detection in background
|
||||
go func() {
|
||||
detector := interlace.NewDetector(platformConfig.FFmpegPath, platformConfig.FFprobePath)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
result, err := detector.QuickAnalyze(ctx, path)
|
||||
|
||||
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
|
||||
state.inspectInterlaceAnalyzing = false
|
||||
if err != nil {
|
||||
logging.Debug(logging.CatSystem, "auto interlacing analysis failed: %v", err)
|
||||
state.inspectInterlaceResult = nil
|
||||
} else {
|
||||
state.inspectInterlaceResult = result
|
||||
logging.Debug(logging.CatSystem, "auto interlacing analysis complete: %s", result.Status)
|
||||
}
|
||||
state.showInspectView() // Refresh to show results
|
||||
}, false)
|
||||
}()
|
||||
}, state.window)
|
||||
})
|
||||
|
||||
// Copy metadata button
|
||||
copyBtn := widget.NewButton("Copy Metadata", func() {
|
||||
if state.inspectFile == nil {
|
||||
return
|
||||
}
|
||||
metadata := formatMetadata(state.inspectFile)
|
||||
state.window.Clipboard().SetContent(metadata)
|
||||
dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window)
|
||||
})
|
||||
copyBtn.Importance = widget.LowImportance
|
||||
|
||||
logPath := ""
|
||||
if state.inspectFile != nil {
|
||||
base := strings.TrimSuffix(filepath.Base(state.inspectFile.Path), filepath.Ext(state.inspectFile.Path))
|
||||
p := filepath.Join(getLogsDir(), base+conversionLogSuffix)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
logPath = p
|
||||
}
|
||||
}
|
||||
viewLogBtn := widget.NewButton("View Conversion Log", func() {
|
||||
if logPath == "" {
|
||||
dialog.ShowInformation("No Log", "No conversion log found for this file.", state.window)
|
||||
return
|
||||
}
|
||||
state.openLogViewer("Conversion Log", logPath, false)
|
||||
})
|
||||
viewLogBtn.Importance = widget.LowImportance
|
||||
if logPath == "" {
|
||||
viewLogBtn.Disable()
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
actionButtons := container.NewHBox(loadBtn, copyBtn, viewLogBtn, clearBtn)
|
||||
|
||||
// Main layout: left side is video player, right side is metadata
|
||||
leftColumn := container.NewBorder(
|
||||
fileLabel,
|
||||
nil, nil, nil,
|
||||
videoContainer,
|
||||
)
|
||||
|
||||
rightColumn := container.NewBorder(
|
||||
widget.NewLabel("Metadata:"),
|
||||
nil, nil, nil,
|
||||
metadataScroll,
|
||||
)
|
||||
|
||||
// Bottom bar with module color
|
||||
bottomBar = moduleFooter(inspectColor, layout.NewSpacer(), state.statsBar)
|
||||
|
||||
// Main content
|
||||
content := container.NewBorder(
|
||||
container.NewVBox(instructionsRow, actionButtons, widget.NewSeparator()),
|
||||
nil, nil, nil,
|
||||
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
||||
)
|
||||
|
||||
return container.NewBorder(topBar, bottomBar, nil, nil, content)
|
||||
}
|
||||
|
||||
// buildThumbView creates the thumbnail generation UI
|
||||
func buildThumbView(state *appState) fyne.CanvasObject {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user