Rename Thumb module to Thumbnail throughout codebase

- Rename thumb_module.go to thumbnail_module.go and thumb_config.go to thumbnail_config.go
- Update all function names from showThumbView/addThumbSource/etc to showThumbnailView/addThumbnailSource/etc
- Update all struct fields from thumbFile/thumbFiles/etc to thumbnailFile/thumbnailFiles/etc
- Update config struct from thumbConfig to thumbnailConfig
- Update job type from JobTypeThumb to JobTypeThumbnail
- Update module registration from 'thumb' to 'thumbnail' in main.go
- Update UI components to show 'THUMBNAIL' instead of 'THUMB'
- Update handler function from HandleThumb to HandleThumbnail
- Update file naming patterns from 'thumb_*.jpg' to 'thumbnail_*.jpg'
- Update author module references to use Thumbnail naming
- Update documentation in QUEUE_SYSTEM_GUIDE.md
- Maintains all existing functionality while using clearer naming
This commit is contained in:
VideoTools CI 2026-01-17 04:15:39 -05:00
parent 965242a767
commit c039ed2753
11 changed files with 681 additions and 378 deletions

View File

@ -0,0 +1,303 @@
# VideoTools Localization Policy
## Overview
VideoTools uses a single authoritative localization system for all user-facing strings. Fyne's built-in localization is used only for system-level UI elements where we have limited influence. This ensures terminology control, Canadian language priorities, and Indigenous language quality requirements.
## Language Support Strategy
### Priority Order
1. **English (en-CA)** - Source language and authoritative default
2. **French (fr-CA)** - Official language parity for Canada
3. **Indigenous Languages** - Inuktitut, Cree (phased rollout with human review)
4. **Global Languages** - Future expansion when resources allow
### Language Pack Structure
```
localization/
├── en-CA/
│ ├── meta.json
│ ├── common.json
│ ├── convert.json
│ ├── merge.json
│ ├── player.json
│ ├── ui.json
│ └── errors.json
├── fr-CA/
│ ├── meta.json
│ ├── [same module structure as en-CA]
├── iu/
│ ├── meta.json
│ ├── common.syllabics.json
│ ├── common.roman.json
│ ├── convert.syllabics.json
│ ├── convert.roman.json
│ └── [other modules with dual scripts]
├── cr/
│ ├── meta.json
│ ├── [dual-script structure like iu]
└── README.md
```
## Authoritative Language Rules
### Canadian English (en-CA) Policy
- en-CA is **source and authoritative** language
- All localization keys originate in en-CA
- Spelling follows Canadian conventions: colour, centre, licence, defence
- Technical vocabulary uses North American terms: program (not programme), truck (not lorry)
- en-US is a **derived localization**, not a fallback
### Branding Rules
#### VT Brand Mark
- **VT is never translated**
- VT is never rendered in non-Latin scripts
- VT is never localized or modified
#### VideoTools Product Name
- Remains untranslated in Latin-script languages
- Translated by **meaning only** for non-Latin scripts
- Treated as display text, not part of logo asset
- Translation must reflect "tools for working with video," not phonetic approximation
### Script Handling
#### Indigenous Languages with Dual Scripts
- **Primary script**: Syllabics (first-class display)
- **Secondary script**: Romanized (accessibility/toggle option)
- Both scripts must be **human-reviewed** and approved
- No machine translation or automatic transliteration
#### Script Selection Logic
1. User-selected language + primary script
2. User-selected language + secondary script (if enabled)
3. English (en-CA)
4. Safe fallback string
## Terminology Rules
### Core Media Concepts
#### Movie vs Film (Strict Enforcement)
- **Movie**: Completed audiovisual work (digital files: MP4, MKV, MOV, AVI)
- **Film**: Physical or photochemical medium (8mm, 16mm, 35mm, reels, negatives)
- **Never interchangeable** in VT UI or documentation
#### Technical Terms
- **Video**: Visual component of audiovisual signal
- **Audio**: Sound component of media file
- **Container**: File format encapsulating streams (MP4, MKV, MOV)
- **Codec**: Encoding/decoding method (H.264, HEVC, AV1, AAC)
- **Stream**: Individual encoded channel within container
### User Interface Terminology
#### Standardized Terms
- **Tool**: Functional component performing specific task
- **Settings**: User-configurable options (preferred over "Preferences")
- **Menu**: Navigational UI element grouping actions
- **Module**: Major functional area within VT
#### Avoided Terms
Do not use unless explicitly defined:
- Media (too broad without context)
- Content (unless clearly scoped)
- Clip (ambiguous)
- Footage (unless raw source material)
## String Key Architecture
### Key Naming Convention
```
[module].[category].[item]
```
#### Examples
- `convert.output.format` - Output format dropdown label
- `merge.chapters.enable` - Chapter preservation checkbox
- `player.controls.play` - Play button label
- `ui.menu.file` - File menu header
- `error.file.not_found` - File not found error message
- `common.button.ok` - Generic OK button
### Key Rules
- **Semantic keys only** - never English strings as keys
- **Hierarchical organization** by module and category
- **Stable keys** - once defined, keys do not change
- **Descriptive, not terse** - `error.file.not_found` vs `file_error`
## Localization Implementation
### Wrapper API Design
#### Primary Localization Function
```go
func T(key string, args ...interface{}) string
```
#### Optional Generic Wrapper
```go
func Generic(key string) string // Only for whitelisted common terms
```
#### Development Guidelines
- **Always use T()** for VT-specific content
- **Use Generic()** only for approved common terms (OK, Cancel)
- **Never mix systems** within single UI component
- **All domain/technical terms** use T()
### Fyne Integration Limits
#### Fyne Localization Scope
- File dialogs (open, save, folder selection)
- System error dialogs (where we have no control)
- Optional: whitelisted generic terms only
#### Fyne Translation Treatment
- Treated as **non-authoritative** conveniences
- Do not let Fyne keys leak into VT string IDs
- Never rely on Fyne for VT-specific terminology
## Quality Assurance
### Translation Requirements
#### All Languages
- Human translation required
- Context provided for all keys
- Terminology consistency enforced via glossary
- UI testing with actual translations
#### Indigenous Languages (Additional Requirements)
- **Human-reviewed by fluent speaker or language authority**
- **No machine translation**
- **Syllabics validation** for proper rendering
- **Cultural appropriateness review**
- **Community feedback** incorporated before release
### Testing Strategy
#### Automated Testing
- **Translation completeness**: All keys exist in each language
- **Key consistency**: No orphaned or deprecated keys
- **UI layout expansion**: Test with long strings (pseudo-language)
- **Script rendering**: Test syllabics and right-to-left support
#### Manual Testing
- **In-context verification**: All UI elements with actual translations
- **Module-by-module validation**: Each module tested independently
- **Language switching**: Dynamic switching functionality
- **Error conditions**: Error messages with all supported languages
## Contribution Guidelines
### Translation Contributions
#### General Languages
- Pull requests for new translations welcome
- Must follow key naming conventions
- Include context for ambiguous terms
- Maintain consistency with existing terminology
#### Indigenous Languages
- **Must include reviewer credit/approval**
- **Dual-script preferred** (syllabics + romanized)
- **Cultural notes** included for context
- **Community consultation** evidence encouraged
### Translation Review Process
#### Reviewer Requirements
- Fluent in target language
- Familiar with video processing terminology
- Understanding of Canadian context (for French/Indigenous languages)
#### Review Checklist
- [ ] Terminology consistency with glossary
- [ ] Cultural appropriateness
- [ ] Technical accuracy
- [ ] Script rendering correctness
- [ ] Context preservation
## Configuration Integration
### Language Preference Storage
#### Configuration File
```json
{
"language": "en-CA",
"secondaryScript": false,
"autoDetect": true,
"fallbackLanguage": "en-CA"
}
```
#### Fallback Chain
1. User-selected language
2. User-selected language + secondary script (if enabled)
3. English (en-CA)
4. Hardcoded emergency string
### Migration Path
#### Existing Configurations
- Language preference added to existing config files
- Backward compatibility maintained
- Graceful fallback to en-CA if language not set
## File Management
### Translation File Format
#### JSON Structure
```json
{
"meta": {
"language": "Inuktitut",
"region": "CA",
"script": "syllabics",
"reviewer": "Inuit Language Authority",
"version": "2024.01"
},
"translations": {
"convert.output.format": "ᐃᓚᓴᐃᔭᕐᓂᖅ ᐱᓕᕆᔭᐅᔪᖅ",
"common.button.ok": "ᐱᕙᕚᐅᖅᑐᖅ",
"error.file.not_found": "ᐊᓂᑦ ᑎᑎᖃᕐᕕᒃ ᐱᓕᕆᐊᖅᑐᐃᓐᓇᐃᒃ"
}
}
```
#### Validation Rules
- Valid JSON structure
- All required keys present
- No duplicate keys
- Proper UTF-8 encoding
- Script-appropriate characters
## Future Considerations
### Scalability
- Modular structure allows new language addition without core changes
- Separate files per module enable distributed translation work
- Metadata system supports language evolution
### Technical Debt Prevention
- Single authoritative system prevents fragmentation
- Semantic keys prevent coupling to specific languages
- Documentation ensures long-term consistency
### Community Growth
- Clear contribution guidelines enable community participation
- Review process maintains quality
- Indigenous language requirements ensure cultural respect
## Status
This policy is authoritative and binding for all VideoTools localization work. It applies to UI text, documentation, and all external-facing content.

View File

@ -34,58 +34,58 @@ import (
) )
type authorConfig struct { type authorConfig struct {
OutputType string `json:"outputType"` OutputType string `json:"outputType"`
Region string `json:"region"` Region string `json:"region"`
AspectRatio string `json:"aspectRatio"` AspectRatio string `json:"aspectRatio"`
DiscSize string `json:"discSize"` DiscSize string `json:"discSize"`
Title string `json:"title"` Title string `json:"title"`
CreateMenu bool `json:"createMenu"` CreateMenu bool `json:"createMenu"`
MenuTemplate string `json:"menuTemplate"` MenuTemplate string `json:"menuTemplate"`
MenuTheme string `json:"menuTheme"` MenuTheme string `json:"menuTheme"`
MenuBackgroundImage string `json:"menuBackgroundImage"` MenuBackgroundImage string `json:"menuBackgroundImage"`
MenuTitleLogoEnabled bool `json:"menuTitleLogoEnabled"` MenuTitleLogoEnabled bool `json:"menuTitleLogoEnabled"`
MenuTitleLogoPath string `json:"menuTitleLogoPath"` MenuTitleLogoPath string `json:"menuTitleLogoPath"`
MenuTitleLogoPosition string `json:"menuTitleLogoPosition"` MenuTitleLogoPosition string `json:"menuTitleLogoPosition"`
MenuTitleLogoScale float64 `json:"menuTitleLogoScale"` MenuTitleLogoScale float64 `json:"menuTitleLogoScale"`
MenuTitleLogoMargin int `json:"menuTitleLogoMargin"` MenuTitleLogoMargin int `json:"menuTitleLogoMargin"`
MenuStudioLogoEnabled bool `json:"menuStudioLogoEnabled"` MenuStudioLogoEnabled bool `json:"menuStudioLogoEnabled"`
MenuStudioLogoPath string `json:"menuStudioLogoPath"` MenuStudioLogoPath string `json:"menuStudioLogoPath"`
MenuStudioLogoPosition string `json:"menuStudioLogoPosition"` MenuStudioLogoPosition string `json:"menuStudioLogoPosition"`
MenuStudioLogoScale float64 `json:"menuStudioLogoScale"` MenuStudioLogoScale float64 `json:"menuStudioLogoScale"`
MenuStudioLogoMargin int `json:"menuStudioLogoMargin"` MenuStudioLogoMargin int `json:"menuStudioLogoMargin"`
MenuStructure string `json:"menuStructure"` MenuStructure string `json:"menuStructure"`
MenuExtrasEnabled bool `json:"menuExtrasEnabled"` MenuExtrasEnabled bool `json:"menuExtrasEnabled"`
MenuChapterThumbSrc string `json:"menuChapterThumbSrc"` MenuChapterThumbSrc string `json:"menuChapterThumbSrc"`
TreatAsChapters bool `json:"treatAsChapters"` TreatAsChapters bool `json:"treatAsChapters"`
SceneThreshold float64 `json:"sceneThreshold"` SceneThreshold float64 `json:"sceneThreshold"`
} }
func defaultAuthorConfig() authorConfig { func defaultAuthorConfig() authorConfig {
return authorConfig{ return authorConfig{
OutputType: "dvd", OutputType: "dvd",
Region: "AUTO", Region: "AUTO",
AspectRatio: "AUTO", AspectRatio: "AUTO",
DiscSize: "DVD5", DiscSize: "DVD5",
Title: "", Title: "",
CreateMenu: false, CreateMenu: false,
MenuTemplate: "Simple", MenuTemplate: "Simple",
MenuTheme: "VideoTools", MenuTheme: "VideoTools",
MenuBackgroundImage: "", MenuBackgroundImage: "",
MenuTitleLogoEnabled: false, MenuTitleLogoEnabled: false,
MenuTitleLogoPath: "", MenuTitleLogoPath: "",
MenuTitleLogoPosition: "Center", MenuTitleLogoPosition: "Center",
MenuTitleLogoScale: 1.0, MenuTitleLogoScale: 1.0,
MenuTitleLogoMargin: 24, MenuTitleLogoMargin: 24,
MenuStudioLogoEnabled: true, MenuStudioLogoEnabled: true,
MenuStudioLogoPath: "", MenuStudioLogoPath: "",
MenuStudioLogoPosition: "Top Right", MenuStudioLogoPosition: "Top Right",
MenuStudioLogoScale: 1.0, MenuStudioLogoScale: 1.0,
MenuStudioLogoMargin: 24, MenuStudioLogoMargin: 24,
MenuStructure: "Feature + Chapters", MenuStructure: "Feature + Chapters",
MenuExtrasEnabled: false, MenuExtrasEnabled: false,
MenuChapterThumbSrc: "Auto", MenuChapterThumbSrc: "Auto",
TreatAsChapters: false, TreatAsChapters: false,
SceneThreshold: 0.3, SceneThreshold: 0.3,
} }
} }
@ -181,37 +181,37 @@ func (s *appState) applyAuthorConfig(cfg authorConfig) {
s.authorMenuStudioLogoMargin = cfg.MenuStudioLogoMargin s.authorMenuStudioLogoMargin = cfg.MenuStudioLogoMargin
s.authorMenuStructure = cfg.MenuStructure s.authorMenuStructure = cfg.MenuStructure
s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled s.authorMenuExtrasEnabled = cfg.MenuExtrasEnabled
s.authorMenuChapterThumbSrc = cfg.MenuChapterThumbSrc s.authorMenuChapterThumbnailSrc = cfg.MenuChapterThumbSrc
s.authorTreatAsChapters = cfg.TreatAsChapters s.authorTreatAsChapters = cfg.TreatAsChapters
s.authorSceneThreshold = cfg.SceneThreshold s.authorSceneThreshold = cfg.SceneThreshold
} }
func (s *appState) persistAuthorConfig() { func (s *appState) persistAuthorConfig() {
cfg := authorConfig{ cfg := authorConfig{
OutputType: s.authorOutputType, OutputType: s.authorOutputType,
Region: s.authorRegion, Region: s.authorRegion,
AspectRatio: s.authorAspectRatio, AspectRatio: s.authorAspectRatio,
DiscSize: s.authorDiscSize, DiscSize: s.authorDiscSize,
Title: s.authorTitle, Title: s.authorTitle,
CreateMenu: s.authorCreateMenu, CreateMenu: s.authorCreateMenu,
MenuTemplate: s.authorMenuTemplate, MenuTemplate: s.authorMenuTemplate,
MenuTheme: s.authorMenuTheme, MenuTheme: s.authorMenuTheme,
MenuBackgroundImage: s.authorMenuBackgroundImage, MenuBackgroundImage: s.authorMenuBackgroundImage,
MenuTitleLogoEnabled: s.authorMenuTitleLogoEnabled, MenuTitleLogoEnabled: s.authorMenuTitleLogoEnabled,
MenuTitleLogoPath: s.authorMenuTitleLogoPath, MenuTitleLogoPath: s.authorMenuTitleLogoPath,
MenuTitleLogoPosition: s.authorMenuTitleLogoPosition, MenuTitleLogoPosition: s.authorMenuTitleLogoPosition,
MenuTitleLogoScale: s.authorMenuTitleLogoScale, MenuTitleLogoScale: s.authorMenuTitleLogoScale,
MenuTitleLogoMargin: s.authorMenuTitleLogoMargin, MenuTitleLogoMargin: s.authorMenuTitleLogoMargin,
MenuStudioLogoEnabled: s.authorMenuStudioLogoEnabled, MenuStudioLogoEnabled: s.authorMenuStudioLogoEnabled,
MenuStudioLogoPath: s.authorMenuStudioLogoPath, MenuStudioLogoPath: s.authorMenuStudioLogoPath,
MenuStudioLogoPosition: s.authorMenuStudioLogoPosition, MenuStudioLogoPosition: s.authorMenuStudioLogoPosition,
MenuStudioLogoScale: s.authorMenuStudioLogoScale, MenuStudioLogoScale: s.authorMenuStudioLogoScale,
MenuStudioLogoMargin: s.authorMenuStudioLogoMargin, MenuStudioLogoMargin: s.authorMenuStudioLogoMargin,
MenuStructure: s.authorMenuStructure, MenuStructure: s.authorMenuStructure,
MenuExtrasEnabled: s.authorMenuExtrasEnabled, MenuExtrasEnabled: s.authorMenuExtrasEnabled,
MenuChapterThumbSrc: s.authorMenuChapterThumbSrc, MenuChapterThumbSrc: s.authorMenuChapterThumbnailSrc,
TreatAsChapters: s.authorTreatAsChapters, TreatAsChapters: s.authorTreatAsChapters,
SceneThreshold: s.authorSceneThreshold, SceneThreshold: s.authorSceneThreshold,
} }
if err := savePersistedAuthorConfig(cfg); err != nil { if err := savePersistedAuthorConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist author config: %v", err) logging.Debug(logging.CatSystem, "failed to persist author config: %v", err)
@ -266,8 +266,8 @@ func buildAuthorView(state *appState) fyne.CanvasObject {
if state.authorMenuStructure == "" { if state.authorMenuStructure == "" {
state.authorMenuStructure = "Feature + Chapters" state.authorMenuStructure = "Feature + Chapters"
} }
if state.authorMenuChapterThumbSrc == "" { if state.authorMenuChapterThumbnailSrc == "" {
state.authorMenuChapterThumbSrc = "Auto" state.authorMenuChapterThumbnailSrc = "Auto"
} }
authorColor := moduleColor("author") authorColor := moduleColor("author")
@ -904,30 +904,30 @@ func buildAuthorSettingsTab(state *appState) fyne.CanvasObject {
saveCfgBtn := widget.NewButton("Save Config", func() { saveCfgBtn := widget.NewButton("Save Config", func() {
cfg := authorConfig{ cfg := authorConfig{
OutputType: state.authorOutputType, OutputType: state.authorOutputType,
Region: state.authorRegion, Region: state.authorRegion,
AspectRatio: state.authorAspectRatio, AspectRatio: state.authorAspectRatio,
DiscSize: state.authorDiscSize, DiscSize: state.authorDiscSize,
Title: state.authorTitle, Title: state.authorTitle,
CreateMenu: state.authorCreateMenu, CreateMenu: state.authorCreateMenu,
MenuTemplate: state.authorMenuTemplate, MenuTemplate: state.authorMenuTemplate,
MenuTheme: state.authorMenuTheme, MenuTheme: state.authorMenuTheme,
MenuBackgroundImage: state.authorMenuBackgroundImage, MenuBackgroundImage: state.authorMenuBackgroundImage,
MenuTitleLogoEnabled: state.authorMenuTitleLogoEnabled, MenuTitleLogoEnabled: state.authorMenuTitleLogoEnabled,
MenuTitleLogoPath: state.authorMenuTitleLogoPath, MenuTitleLogoPath: state.authorMenuTitleLogoPath,
MenuTitleLogoPosition: state.authorMenuTitleLogoPosition, MenuTitleLogoPosition: state.authorMenuTitleLogoPosition,
MenuTitleLogoScale: state.authorMenuTitleLogoScale, MenuTitleLogoScale: state.authorMenuTitleLogoScale,
MenuTitleLogoMargin: state.authorMenuTitleLogoMargin, MenuTitleLogoMargin: state.authorMenuTitleLogoMargin,
MenuStudioLogoEnabled: state.authorMenuStudioLogoEnabled, MenuStudioLogoEnabled: state.authorMenuStudioLogoEnabled,
MenuStudioLogoPath: state.authorMenuStudioLogoPath, MenuStudioLogoPath: state.authorMenuStudioLogoPath,
MenuStudioLogoPosition: state.authorMenuStudioLogoPosition, MenuStudioLogoPosition: state.authorMenuStudioLogoPosition,
MenuStudioLogoScale: state.authorMenuStudioLogoScale, MenuStudioLogoScale: state.authorMenuStudioLogoScale,
MenuStudioLogoMargin: state.authorMenuStudioLogoMargin, MenuStudioLogoMargin: state.authorMenuStudioLogoMargin,
MenuStructure: state.authorMenuStructure, MenuStructure: state.authorMenuStructure,
MenuExtrasEnabled: state.authorMenuExtrasEnabled, MenuExtrasEnabled: state.authorMenuExtrasEnabled,
MenuChapterThumbSrc: state.authorMenuChapterThumbSrc, MenuChapterThumbSrc: state.authorMenuChapterThumbnailSrc,
TreatAsChapters: state.authorTreatAsChapters, TreatAsChapters: state.authorTreatAsChapters,
SceneThreshold: state.authorSceneThreshold, SceneThreshold: state.authorSceneThreshold,
} }
if err := savePersistedAuthorConfig(cfg); err != nil { if err := savePersistedAuthorConfig(cfg); err != nil {
dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window) dialog.ShowError(fmt.Errorf("failed to save config: %w", err), state.window)
@ -1319,13 +1319,13 @@ func buildAuthorMenuTab(state *appState) fyne.CanvasObject {
"Midpoint", "Midpoint",
"Custom (Advanced)", "Custom (Advanced)",
}, func(value string) { }, func(value string) {
state.authorMenuChapterThumbSrc = value state.authorMenuChapterThumbnailSrc = value
state.persistAuthorConfig() state.persistAuthorConfig()
}) })
if state.authorMenuChapterThumbSrc == "" { if state.authorMenuChapterThumbnailSrc == "" {
state.authorMenuChapterThumbSrc = "Auto" state.authorMenuChapterThumbnailSrc = "Auto"
} }
thumbSourceSelect.SetSelected(state.authorMenuChapterThumbSrc) thumbSourceSelect.SetSelected(state.authorMenuChapterThumbnailSrc)
info := widget.NewLabel("DVD menus are generated using the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.") info := widget.NewLabel("DVD menus are generated using the VideoTools theme and IBM Plex Mono. Menu settings apply only to disc authoring.")
info.Wrapping = fyne.TextWrapWord info.Wrapping = fyne.TextWrapWord
@ -2396,40 +2396,40 @@ func (s *appState) addAuthorToQueue(paths []string, region, aspect, title, outpu
} }
config := map[string]interface{}{ config := map[string]interface{}{
"paths": paths, "paths": paths,
"region": region, "region": region,
"aspect": aspect, "aspect": aspect,
"title": title, "title": title,
"outputPath": outputPath, "outputPath": outputPath,
"makeISO": makeISO, "makeISO": makeISO,
"treatAsChapters": s.authorTreatAsChapters, "treatAsChapters": s.authorTreatAsChapters,
"clips": clips, "clips": clips,
"chapters": chapters, "chapters": chapters,
"discSize": s.authorDiscSize, "discSize": s.authorDiscSize,
"outputType": s.authorOutputType, "outputType": s.authorOutputType,
"authorTitle": s.authorTitle, "authorTitle": s.authorTitle,
"authorRegion": s.authorRegion, "authorRegion": s.authorRegion,
"authorAspect": s.authorAspectRatio, "authorAspect": s.authorAspectRatio,
"createMenu": s.authorCreateMenu, "createMenu": s.authorCreateMenu,
"chapterSource": s.authorChapterSource, "chapterSource": s.authorChapterSource,
"subtitleTracks": append([]string{}, s.authorSubtitles...), "subtitleTracks": append([]string{}, s.authorSubtitles...),
"additionalAudios": append([]string{}, s.authorAudioTracks...), "additionalAudios": append([]string{}, s.authorAudioTracks...),
"menuTemplate": s.authorMenuTemplate, "menuTemplate": s.authorMenuTemplate,
"menuBackgroundImage": s.authorMenuBackgroundImage, "menuBackgroundImage": s.authorMenuBackgroundImage,
"menuTheme": s.authorMenuTheme, "menuTheme": s.authorMenuTheme,
"menuTitleLogoEnabled": s.authorMenuTitleLogoEnabled, "menuTitleLogoEnabled": s.authorMenuTitleLogoEnabled,
"menuTitleLogoPath": s.authorMenuTitleLogoPath, "menuTitleLogoPath": s.authorMenuTitleLogoPath,
"menuTitleLogoPosition": s.authorMenuTitleLogoPosition, "menuTitleLogoPosition": s.authorMenuTitleLogoPosition,
"menuTitleLogoScale": s.authorMenuTitleLogoScale, "menuTitleLogoScale": s.authorMenuTitleLogoScale,
"menuTitleLogoMargin": s.authorMenuTitleLogoMargin, "menuTitleLogoMargin": s.authorMenuTitleLogoMargin,
"menuStudioLogoEnabled": s.authorMenuStudioLogoEnabled, "menuStudioLogoEnabled": s.authorMenuStudioLogoEnabled,
"menuStudioLogoPath": s.authorMenuStudioLogoPath, "menuStudioLogoPath": s.authorMenuStudioLogoPath,
"menuStudioLogoPosition": s.authorMenuStudioLogoPosition, "menuStudioLogoPosition": s.authorMenuStudioLogoPosition,
"menuStudioLogoScale": s.authorMenuStudioLogoScale, "menuStudioLogoScale": s.authorMenuStudioLogoScale,
"menuStudioLogoMargin": s.authorMenuStudioLogoMargin, "menuStudioLogoMargin": s.authorMenuStudioLogoMargin,
"menuStructure": s.authorMenuStructure, "menuStructure": s.authorMenuStructure,
"menuExtrasEnabled": s.authorMenuExtrasEnabled, "menuExtrasEnabled": s.authorMenuExtrasEnabled,
"menuChapterThumbSrc": s.authorMenuChapterThumbSrc, "menuChapterThumbSrc": s.authorMenuChapterThumbnailSrc,
} }
titleLabel := title titleLabel := title
@ -3725,7 +3725,7 @@ func extractChapterThumbnail(videoPath string, timestamp float64) (string, error
return "", err return "", err
} }
outputPath := filepath.Join(tmpDir, fmt.Sprintf("thumb_%.2f.jpg", timestamp)) outputPath := filepath.Join(tmpDir, fmt.Sprintf("thumbnail_%.2f.jpg", timestamp))
args := []string{ args := []string{
"-ss", fmt.Sprintf("%.2f", timestamp), "-ss", fmt.Sprintf("%.2f", timestamp),
"-i", videoPath, "-i", videoPath,

View File

@ -34,7 +34,7 @@ const (
JobTypeFilter JobType = "filter" // Effects/filters JobTypeFilter JobType = "filter" // Effects/filters
JobTypeUpscale JobType = "upscale" // Video enhancement JobTypeUpscale JobType = "upscale" // Video enhancement
JobTypeAudio JobType = "audio" // Audio processing JobTypeAudio JobType = "audio" // Audio processing
JobTypeThumb JobType = "thumb" // Thumbnail generation JobTypeThumbnail JobType = "thumbnail" // Thumbnail generation
) )
``` ```

View File

@ -71,10 +71,10 @@ func HandleSubtitles(files []string) {
fmt.Println("subtitles", files) fmt.Println("subtitles", files)
} }
// HandleThumb handles the thumb module // HandleThumbnail handles the thumbnail module
func HandleThumb(files []string) { func HandleThumbnail(files []string) {
logging.Debug(logging.CatModule, "thumb handler invoked with %v", files) logging.Debug(logging.CatModule, "thumbnail handler invoked with %v", files)
fmt.Println("thumb", files) fmt.Println("thumbnail", files)
} }
// HandleInspect handles the inspect module // HandleInspect handles the inspect module

View File

@ -26,7 +26,7 @@ const (
JobTypeRip JobType = "rip" JobTypeRip JobType = "rip"
JobTypeBluray JobType = "bluray" JobTypeBluray JobType = "bluray"
JobTypeSubtitles JobType = "subtitles" JobTypeSubtitles JobType = "subtitles"
JobTypeThumb JobType = "thumb" JobTypeThumbnail JobType = "thumbnail"
JobTypeInspect JobType = "inspect" JobTypeInspect JobType = "inspect"
JobTypeCompare JobType = "compare" JobTypeCompare JobType = "compare"
JobTypePlayer JobType = "player" JobTypePlayer JobType = "player"

View File

@ -329,7 +329,7 @@ func (g *Generator) generateIndividual(ctx context.Context, config Config, durat
// Generate each thumbnail // Generate each thumbnail
for i, ts := range timestamps { for i, ts := range timestamps {
outputPath := filepath.Join(config.OutputDir, fmt.Sprintf("thumb_%04d.%s", i+1, config.Format)) outputPath := filepath.Join(config.OutputDir, fmt.Sprintf("thumbnail_%04d.%s", i+1, config.Format))
// Build FFmpeg command // Build FFmpeg command
args := []string{ args := []string{

View File

@ -1069,9 +1069,9 @@ func BuildModuleBadge(jobType queue.JobType) fyne.CanvasObject {
case queue.JobTypeAudio: case queue.JobTypeAudio:
badgeColor = utils.MustHex("#FFC107") // Amber badgeColor = utils.MustHex("#FFC107") // Amber
badgeText = "AUDIO" badgeText = "AUDIO"
case queue.JobTypeThumb: case queue.JobTypeThumbnail:
badgeColor = utils.MustHex("#00ACC1") // Dark Cyan badgeColor = utils.MustHex("#00ACC1") // Dark Cyan
badgeText = "THUMB" badgeText = "THUMBNAIL"
case queue.JobTypeSnippet: case queue.JobTypeSnippet:
badgeColor = utils.MustHex("#00BCD4") // Cyan (same as Convert) badgeColor = utils.MustHex("#00BCD4") // Cyan (same as Convert)
badgeText = "SNIPPET" badgeText = "SNIPPET"

View File

@ -646,7 +646,7 @@ func ModuleColor(t queue.JobType) color.Color {
return color.RGBA{R: 156, G: 39, B: 176, A: 255} // Purple (#9C27B0) return color.RGBA{R: 156, G: 39, B: 176, A: 255} // Purple (#9C27B0)
case queue.JobTypeAudio: case queue.JobTypeAudio:
return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Amber (#FFC107) return color.RGBA{R: 255, G: 193, B: 7, A: 255} // Amber (#FFC107)
case queue.JobTypeThumb: case queue.JobTypeThumbnail:
return color.RGBA{R: 0, G: 172, B: 193, A: 255} // Dark Cyan (#00ACC1) return color.RGBA{R: 0, G: 172, B: 193, A: 255} // Dark Cyan (#00ACC1)
case queue.JobTypeAuthor: case queue.JobTypeAuthor:
return color.RGBA{R: 255, G: 87, B: 34, A: 255} // Deep Orange (#FF5722) return color.RGBA{R: 255, G: 87, B: 34, A: 255} // Deep Orange (#FF5722)

170
main.go
View File

@ -96,23 +96,23 @@ var (
// Rainbow color palette: balanced ROYGBIV distribution (2 modules per color) // Rainbow color palette: balanced ROYGBIV distribution (2 modules per color)
// Optimized for white text readability // Optimized for white text readability
modulesList = []Module{ modulesList = []Module{
{"convert", "Convert", utils.MustHex("#673AB7"), "Convert", modules.HandleConvert}, // Deep Purple (primary conversion) {"convert", "Convert", utils.MustHex("#673AB7"), "Convert", modules.HandleConvert}, // Deep Purple (primary conversion)
{"merge", "Merge", utils.MustHex("#4CAF50"), "Convert", modules.HandleMerge}, // Green (combining) {"merge", "Merge", utils.MustHex("#4CAF50"), "Convert", modules.HandleMerge}, // Green (combining)
{"trim", "Trim", utils.MustHex("#F9A825"), "Convert", nil}, // Dark Yellow/Gold (not implemented yet) {"trim", "Trim", utils.MustHex("#F9A825"), "Convert", nil}, // Dark Yellow/Gold (not implemented yet)
{"filters", "Filters", utils.MustHex("#00BCD4"), "Convert", modules.HandleFilters}, // Cyan (creative filters) {"filters", "Filters", utils.MustHex("#00BCD4"), "Convert", modules.HandleFilters}, // Cyan (creative filters)
{"upscale", "Upscale", utils.MustHex("#9C27B0"), "Advanced", modules.HandleUpscale}, // Purple (AI/advanced) {"upscale", "Upscale", utils.MustHex("#9C27B0"), "Advanced", modules.HandleUpscale}, // Purple (AI/advanced)
{"enhancement", "Enhancement", utils.MustHex("#7C3AED"), "Advanced", modules.HandleEnhance}, // Cyan (AI enhancement) {"enhancement", "Enhancement", utils.MustHex("#7C3AED"), "Advanced", modules.HandleEnhance}, // Cyan (AI enhancement)
{"audio", "Audio", utils.MustHex("#FF8F00"), "Convert", modules.HandleAudio}, // Dark Amber - audio extraction {"audio", "Audio", utils.MustHex("#FF8F00"), "Convert", modules.HandleAudio}, // Dark Amber - audio extraction
{"author", "Author", utils.MustHex("#FF5722"), "Disc", modules.HandleAuthor}, // Deep Orange (authoring) {"author", "Author", utils.MustHex("#FF5722"), "Disc", modules.HandleAuthor}, // Deep Orange (authoring)
{"rip", "Rip", utils.MustHex("#FF9800"), "Disc", modules.HandleRip}, // Orange (extraction) {"rip", "Rip", utils.MustHex("#FF9800"), "Disc", modules.HandleRip}, // Orange (extraction)
{"bluray", "Blu-Ray", utils.MustHex("#2196F3"), "Disc", nil}, // Blue (not implemented yet) {"bluray", "Blu-Ray", utils.MustHex("#2196F3"), "Disc", nil}, // Blue (not implemented yet)
{"subtitles", "Subtitles", utils.MustHex("#689F38"), "Convert", modules.HandleSubtitles}, // Dark Green (text) {"subtitles", "Subtitles", utils.MustHex("#689F38"), "Convert", modules.HandleSubtitles}, // Dark Green (text)
{"enhancement", "Enhancement", utils.MustHex("#7C3AED"), "Advanced", modules.HandleEnhance}, // Cyan (AI enhancement) {"enhancement", "Enhancement", utils.MustHex("#7C3AED"), "Advanced", modules.HandleEnhance}, // Cyan (AI enhancement)
{"thumb", "Thumb", utils.MustHex("#00ACC1"), "Screenshots", modules.HandleThumb}, // Dark Cyan (capture) {"thumbnail", "Thumbnail", utils.MustHex("#00ACC1"), "Screenshots", modules.HandleThumbnail}, // Dark Cyan (capture)
{"compare", "Compare", utils.MustHex("#E91E63"), "Inspect", modules.HandleCompare}, // Pink (comparison) {"compare", "Compare", utils.MustHex("#E91E63"), "Inspect", modules.HandleCompare}, // Pink (comparison)
{"inspect", "Inspect", utils.MustHex("#F44336"), "Inspect", modules.HandleInspect}, // Red (analysis) {"inspect", "Inspect", utils.MustHex("#F44336"), "Inspect", modules.HandleInspect}, // Red (analysis)
{"player", "Player", utils.MustHex("#3F51B5"), "Playback", modules.HandlePlayer}, // Indigo (playback) {"player", "Player", utils.MustHex("#3F51B5"), "Playback", modules.HandlePlayer}, // Indigo (playback)
{"settings", "Settings", utils.MustHex("#607D8B"), "Settings", nil}, // Blue Grey (settings) {"settings", "Settings", utils.MustHex("#607D8B"), "Settings", nil}, // Blue Grey (settings)
} }
// Platform-specific configuration // Platform-specific configuration
@ -1075,16 +1075,16 @@ type appState struct {
mergeMotionInterpolation bool // Use motion interpolation for frame rate changes mergeMotionInterpolation bool // Use motion interpolation for frame rate changes
// Thumbnail module state // Thumbnail module state
thumbFile *videoSource thumbnailFile *videoSource
thumbFiles []*videoSource thumbnailFiles []*videoSource
thumbCount int thumbnailCount int
thumbWidth int thumbnailWidth int
thumbContactSheet bool thumbnailContactSheet bool
thumbShowTimestamps bool thumbnailShowTimestamps bool
thumbSheetWidth int thumbnailSheetWidth int
thumbColumns int thumbnailColumns int
thumbRows int thumbnailRows int
thumbLastOutputPath string // Path to last generated output thumbnailLastOutputPath string // Path to last generated output
// Player module state // Player module state
playerFile *videoSource playerFile *videoSource
@ -1165,49 +1165,49 @@ type appState struct {
historyTabIdx int historyTabIdx int
// Author module state // Author module state
authorFile *videoSource authorFile *videoSource
authorChapters []authorChapter authorChapters []authorChapter
authorSceneThreshold float64 authorSceneThreshold float64
authorDetecting bool authorDetecting bool
authorClips []authorClip // Multiple video clips for compilation authorClips []authorClip // Multiple video clips for compilation
authorOutputType string // "dvd" or "iso" authorOutputType string // "dvd" or "iso"
authorRegion string // "NTSC", "PAL", "AUTO" authorRegion string // "NTSC", "PAL", "AUTO"
authorAspectRatio string // "4:3", "16:9", "AUTO" authorAspectRatio string // "4:3", "16:9", "AUTO"
authorCreateMenu bool // Whether to create DVD menu authorCreateMenu bool // Whether to create DVD menu
authorMenuTemplate string // "Simple", "Dark", "Poster" authorMenuTemplate string // "Simple", "Dark", "Poster"
authorMenuBackgroundImage string // Path to a user-selected background image authorMenuBackgroundImage string // Path to a user-selected background image
authorMenuTheme string // "VideoTools" authorMenuTheme string // "VideoTools"
authorMenuTitleLogoEnabled bool // Enable title logo (main logo above menu) authorMenuTitleLogoEnabled bool // Enable title logo (main logo above menu)
authorMenuTitleLogoPath string // Path to title logo image authorMenuTitleLogoPath string // Path to title logo image
authorMenuTitleLogoPosition string // Position for title logo authorMenuTitleLogoPosition string // Position for title logo
authorMenuTitleLogoScale float64 // Scale for title logo authorMenuTitleLogoScale float64 // Scale for title logo
authorMenuTitleLogoMargin int // Margin for title logo authorMenuTitleLogoMargin int // Margin for title logo
authorMenuStudioLogoEnabled bool // Enable studio logo (corner logo) authorMenuStudioLogoEnabled bool // Enable studio logo (corner logo)
authorMenuStudioLogoPath string // Path to studio logo image authorMenuStudioLogoPath string // Path to studio logo image
authorMenuStudioLogoPosition string // "Top Left", "Top Right", "Bottom Left", "Bottom Right" authorMenuStudioLogoPosition string // "Top Left", "Top Right", "Bottom Left", "Bottom Right"
authorMenuStudioLogoScale float64 // Scale for studio logo authorMenuStudioLogoScale float64 // Scale for studio logo
authorMenuStudioLogoMargin int // Margin for studio logo authorMenuStudioLogoMargin int // Margin for studio logo
authorMenuStructure string // Feature only, Chapters, Extras authorMenuStructure string // Feature only, Chapters, Extras
authorMenuExtrasEnabled bool // Show extras menu authorMenuExtrasEnabled bool // Show extras menu
authorMenuChapterThumbSrc string // Auto, First Frame, Midpoint, Custom authorMenuChapterThumbnailSrc string // Auto, First Frame, Midpoint, Custom
authorTitle string // DVD title authorTitle string // DVD title
authorSubtitles []string // Subtitle file paths authorSubtitles []string // Subtitle file paths
authorAudioTracks []string // Additional audio tracks authorAudioTracks []string // Additional audio tracks
authorSummaryLabel *widget.Label authorSummaryLabel *widget.Label
authorTreatAsChapters bool // Treat multiple clips as chapters authorTreatAsChapters bool // Treat multiple clips as chapters
authorChapterSource string // embedded, scenes, clips, manual authorChapterSource string // embedded, scenes, clips, manual
authorChaptersRefresh func() // Refresh hook for chapter list UI authorChaptersRefresh func() // Refresh hook for chapter list UI
authorDiscSize string // "DVD5" or "DVD9" authorDiscSize string // "DVD5" or "DVD9"
authorLogText string authorLogText string
authorLogLines []string // Circular buffer for last N lines authorLogLines []string // Circular buffer for last N lines
authorLogFilePath string // Path to log file for full viewing authorLogFilePath string // Path to log file for full viewing
authorLogEntry *widget.Entry authorLogEntry *widget.Entry
authorLogScroll *container.Scroll authorLogScroll *container.Scroll
authorProgress float64 authorProgress float64
authorProgressBar *widget.ProgressBar authorProgressBar *widget.ProgressBar
authorStatusLabel *widget.Label authorStatusLabel *widget.Label
authorCancelBtn *widget.Button authorCancelBtn *widget.Button
authorVideoTSPath string authorVideoTSPath string
// Rip module state // Rip module state
ripSourcePath string ripSourcePath string
@ -3101,8 +3101,8 @@ func (s *appState) showModule(id string) {
s.showCompareView() s.showCompareView()
case "inspect": case "inspect":
s.showInspectView() s.showInspectView()
case "thumb": case "thumbnail":
s.showThumbView() s.showThumbnailView()
case "player": case "player":
s.showPlayerView() s.showPlayerView()
case "filters": case "filters":
@ -3310,13 +3310,13 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
return return
} }
// If thumb module, load video into thumb slot // If thumbnail module, load video into thumbnail slot
if moduleID == "thumb" { if moduleID == "thumbnail" {
path := videoPaths[0] path := videoPaths[0]
go func() { go func() {
src, err := probeVideo(path) src, err := probeVideo(path)
if err != nil { if err != nil {
logging.Debug(logging.CatModule, "failed to load video for thumb: %v", err) logging.Debug(logging.CatModule, "failed to load video for thumbnail: %v", err)
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window) dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
}, false) }, false)
@ -3326,9 +3326,9 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
// Update state and show module (with small delay to allow flash animation) // Update state and show module (with small delay to allow flash animation)
time.Sleep(350 * time.Millisecond) time.Sleep(350 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.thumbFile = src s.thumbnailFile = src
s.showModule(moduleID) s.showModule(moduleID)
logging.Debug(logging.CatModule, "loaded video for thumb module") logging.Debug(logging.CatModule, "loaded video for thumbnail module")
}, false) }, false)
}() }()
return return
@ -4192,8 +4192,8 @@ func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCall
return s.executeUpscaleJob(ctx, job, progressCallback) return s.executeUpscaleJob(ctx, job, progressCallback)
case queue.JobTypeAudio: case queue.JobTypeAudio:
return s.executeAudioJob(ctx, job, progressCallback) return s.executeAudioJob(ctx, job, progressCallback)
case queue.JobTypeThumb: case queue.JobTypeThumbnail:
return s.executeThumbJob(ctx, job, progressCallback) return s.executeThumbnailJob(ctx, job, progressCallback)
case queue.JobTypeSnippet: case queue.JobTypeSnippet:
return s.executeSnippetJob(ctx, job, progressCallback) return s.executeSnippetJob(ctx, job, progressCallback)
case queue.JobTypeAuthor: case queue.JobTypeAuthor:
@ -6828,8 +6828,8 @@ func runCLI(args []string) error {
modules.HandleUpscale(cmdArgs) modules.HandleUpscale(cmdArgs)
case "audio": case "audio":
modules.HandleAudio(cmdArgs) modules.HandleAudio(cmdArgs)
case "thumb": case "thumbnail":
modules.HandleThumb(cmdArgs) modules.HandleThumbnail(cmdArgs)
case "compare": case "compare":
modules.HandleCompare(cmdArgs) modules.HandleCompare(cmdArgs)
case "inspect": case "inspect":
@ -6895,7 +6895,7 @@ func printUsage() {
fmt.Println(" videotools filters <args>") fmt.Println(" videotools filters <args>")
fmt.Println(" videotools upscale <args>") fmt.Println(" videotools upscale <args>")
fmt.Println(" videotools audio <args>") fmt.Println(" videotools audio <args>")
fmt.Println(" videotools thumb <args>") fmt.Println(" videotools thumbnail <args>")
fmt.Println(" videotools compare <file1> <file2>") fmt.Println(" videotools compare <file1> <file2>")
fmt.Println(" videotools inspect <args>") fmt.Println(" videotools inspect <args>")
fmt.Println(" videotools logs # tail recent log lines") fmt.Println(" videotools logs # tail recent log lines")
@ -11018,7 +11018,7 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu
dlg.Show() dlg.Show()
}) })
usePlayer := state.active != "thumb" usePlayer := state.active != "thumbnail"
currentTime := widget.NewLabel("0:00") currentTime := widget.NewLabel("0:00")
totalTime := widget.NewLabel(src.DurationString()) totalTime := widget.NewLabel(src.DurationString())
@ -12483,8 +12483,8 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
return return
} }
// If in thumb module, handle single video file // If in thumbnail module, handle single video file
if s.active == "thumb" { if s.active == "thumbnail" {
// Collect video files from dropped items // Collect video files from dropped items
var videoPaths []string var videoPaths []string
for _, uri := range items { for _, uri := range items {

View File

@ -8,7 +8,7 @@ import (
"git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/logging"
) )
type thumbConfig struct { type thumbnailConfig struct {
ContactSheet bool `json:"contactSheet"` ContactSheet bool `json:"contactSheet"`
ShowTimestamps bool `json:"showTimestamps"` ShowTimestamps bool `json:"showTimestamps"`
Count int `json:"count"` Count int `json:"count"`
@ -18,8 +18,8 @@ type thumbConfig struct {
Rows int `json:"rows"` Rows int `json:"rows"`
} }
func defaultThumbConfig() thumbConfig { func defaultThumbnailConfig() thumbnailConfig {
return thumbConfig{ return thumbnailConfig{
ContactSheet: false, ContactSheet: false,
ShowTimestamps: false, ShowTimestamps: false,
Count: 24, Count: 24,
@ -30,9 +30,9 @@ func defaultThumbConfig() thumbConfig {
} }
} }
func loadPersistedThumbConfig() (thumbConfig, error) { func loadPersistedThumbnailConfig() (thumbnailConfig, error) {
var cfg thumbConfig var cfg thumbnailConfig
path := moduleConfigPath("thumb") path := moduleConfigPath("thumbnail")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return cfg, err return cfg, err
@ -58,8 +58,8 @@ func loadPersistedThumbConfig() (thumbConfig, error) {
return cfg, nil return cfg, nil
} }
func savePersistedThumbConfig(cfg thumbConfig) error { func savePersistedThumbnailConfig(cfg thumbnailConfig) error {
path := moduleConfigPath("thumb") path := moduleConfigPath("thumbnail")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err return err
} }
@ -70,27 +70,27 @@ func savePersistedThumbConfig(cfg thumbConfig) error {
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0o644)
} }
func (s *appState) applyThumbConfig(cfg thumbConfig) { func (s *appState) applyThumbnailConfig(cfg thumbnailConfig) {
s.thumbContactSheet = cfg.ContactSheet s.thumbnailContactSheet = cfg.ContactSheet
s.thumbShowTimestamps = cfg.ShowTimestamps s.thumbnailShowTimestamps = cfg.ShowTimestamps
s.thumbCount = cfg.Count s.thumbnailCount = cfg.Count
s.thumbWidth = cfg.Width s.thumbnailWidth = cfg.Width
s.thumbSheetWidth = cfg.SheetWidth s.thumbnailSheetWidth = cfg.SheetWidth
s.thumbColumns = cfg.Columns s.thumbnailColumns = cfg.Columns
s.thumbRows = cfg.Rows s.thumbnailRows = cfg.Rows
} }
func (s *appState) persistThumbConfig() { func (s *appState) persistThumbnailConfig() {
cfg := thumbConfig{ cfg := thumbnailConfig{
ContactSheet: s.thumbContactSheet, ContactSheet: s.thumbnailContactSheet,
ShowTimestamps: s.thumbShowTimestamps, ShowTimestamps: s.thumbnailShowTimestamps,
Count: s.thumbCount, Count: s.thumbnailCount,
Width: s.thumbWidth, Width: s.thumbnailWidth,
SheetWidth: s.thumbSheetWidth, SheetWidth: s.thumbnailSheetWidth,
Columns: s.thumbColumns, Columns: s.thumbnailColumns,
Rows: s.thumbRows, Rows: s.thumbnailRows,
} }
if err := savePersistedThumbConfig(cfg); err != nil { if err := savePersistedThumbnailConfig(cfg); err != nil {
logging.Debug(logging.CatSystem, "failed to persist thumb config: %v", err) logging.Debug(logging.CatSystem, "failed to persist thumb config: %v", err)
} }
} }

View File

@ -21,33 +21,33 @@ import (
"git.leaktechnologies.dev/stu/VideoTools/internal/utils" "git.leaktechnologies.dev/stu/VideoTools/internal/utils"
) )
func (s *appState) showThumbView() { func (s *appState) showThumbnailView() {
s.stopPreview() s.stopPreview()
s.lastModule = s.active s.lastModule = s.active
s.active = "thumb" s.active = "thumbnail"
if cfg, err := loadPersistedThumbConfig(); err == nil { if cfg, err := loadPersistedThumbnailConfig(); err == nil {
s.applyThumbConfig(cfg) s.applyThumbnailConfig(cfg)
} }
s.setContent(buildThumbView(s)) s.setContent(buildThumbnailView(s))
} }
func (s *appState) addThumbSource(src *videoSource) { func (s *appState) addThumbnailSource(src *videoSource) {
if src == nil { if src == nil {
return return
} }
for _, existing := range s.thumbFiles { for _, existing := range s.thumbnailFiles {
if existing != nil && existing.Path == src.Path { if existing != nil && existing.Path == src.Path {
return return
} }
} }
s.thumbFiles = append(s.thumbFiles, src) s.thumbnailFiles = append(s.thumbnailFiles, src)
} }
func (s *appState) loadThumbSourceAtIndex(idx int) { func (s *appState) loadThumbnailSourceAtIndex(idx int) {
if idx < 0 || idx >= len(s.thumbFiles) { if idx < 0 || idx >= len(s.thumbnailFiles) {
return return
} }
current := s.thumbFiles[idx] current := s.thumbnailFiles[idx]
if current == nil || current.Path == "" { if current == nil || current.Path == "" {
return return
} }
@ -62,16 +62,16 @@ func (s *appState) loadThumbSourceAtIndex(idx int) {
return return
} }
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.thumbFiles[idx] = probed s.thumbnailFiles[idx] = probed
if s.thumbFile != nil && s.thumbFile.Path == path { if s.thumbnailFile != nil && s.thumbnailFile.Path == path {
s.thumbFile = probed s.thumbnailFile = probed
} }
s.showThumbView() s.showThumbnailView()
}, false) }, false)
}() }()
} }
func (s *appState) loadMultipleThumbVideos(paths []string) { func (s *appState) loadMultipleThumbnailVideos(paths []string) {
if len(paths) == 0 { if len(paths) == 0 {
return return
} }
@ -97,19 +97,19 @@ func (s *appState) loadMultipleThumbVideos(paths []string) {
return return
} }
s.thumbFiles = valid s.thumbnailFiles = valid
s.thumbFile = valid[0] s.thumbnailFile = valid[0]
fyne.CurrentApp().Driver().DoFromGoroutine(func() { fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.showThumbView() s.showThumbnailView()
if len(failed) > 0 { if len(failed) > 0 {
logging.Debug(logging.CatModule, "%d file(s) failed to analyze: %s", len(failed), strings.Join(failed, ", ")) logging.Debug(logging.CatModule, "%d file(s) failed to analyze: %s", len(failed), strings.Join(failed, ", "))
} }
}, false) }, false)
} }
func buildThumbView(state *appState) fyne.CanvasObject { func buildThumbnailView(state *appState) fyne.CanvasObject {
thumbColor := moduleColor("thumb") thumbColor := moduleColor("thumbnail")
// Back button // Back button
backBtn := widget.NewButton("< THUMBNAILS", func() { backBtn := widget.NewButton("< THUMBNAILS", func() {
@ -137,20 +137,20 @@ func buildThumbView(state *appState) fyne.CanvasObject {
instructions.Alignment = fyne.TextAlignCenter instructions.Alignment = fyne.TextAlignCenter
// Initialize state defaults // Initialize state defaults
if state.thumbCount == 0 { if state.thumbnailCount == 0 {
state.thumbCount = 24 // Default to 24 thumbnails (good for contact sheets) state.thumbnailCount = 24 // Default to 24 thumbnails (good for contact sheets)
} }
if state.thumbWidth == 0 { if state.thumbnailWidth == 0 {
state.thumbWidth = 320 state.thumbnailWidth = 320
} }
if state.thumbSheetWidth == 0 { if state.thumbnailSheetWidth == 0 {
state.thumbSheetWidth = 360 state.thumbnailSheetWidth = 360
} }
if state.thumbColumns == 0 { if state.thumbnailColumns == 0 {
state.thumbColumns = 4 // 4 columns works well for widescreen videos state.thumbnailColumns = 4 // 4 columns works well for widescreen videos
} }
if state.thumbRows == 0 { if state.thumbnailRows == 0 {
state.thumbRows = 8 // 4x8 = 32 thumbnails state.thumbnailRows = 8 // 4x8 = 32 thumbnails
} }
// File label and video preview // File label and video preview
@ -158,12 +158,12 @@ func buildThumbView(state *appState) fyne.CanvasObject {
fileLabel.TextStyle = fyne.TextStyle{Bold: true} fileLabel.TextStyle = fyne.TextStyle{Bold: true}
var videoContainer fyne.CanvasObject var videoContainer fyne.CanvasObject
if state.thumbFile != nil && state.thumbFile.Width == 0 && state.thumbFile.Height == 0 { if state.thumbnailFile != nil && state.thumbnailFile.Width == 0 && state.thumbnailFile.Height == 0 {
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path))) fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbnailFile.Path)))
videoContainer = container.NewCenter(widget.NewLabel("Loading preview...")) videoContainer = container.NewCenter(widget.NewLabel("Loading preview..."))
} else if state.thumbFile != nil { } else if state.thumbnailFile != nil {
fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbFile.Path))) fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(state.thumbnailFile.Path)))
videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbFile, nil) videoContainer = buildVideoPane(state, fyne.NewSize(480, 270), state.thumbnailFile, nil)
} else { } else {
videoContainer = container.NewCenter(widget.NewLabel("No video loaded")) videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
} }
@ -183,28 +183,28 @@ func buildThumbView(state *appState) fyne.CanvasObject {
return return
} }
state.thumbFile = src state.thumbnailFile = src
state.addThumbSource(src) state.addThumbnailSource(src)
state.showThumbView() state.showThumbnailView()
logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path) logging.Debug(logging.CatModule, "loaded thumbnail file: %s", path)
}, state.window) }, state.window)
}) })
// Clear button // Clear button
clearBtn := widget.NewButton("Clear", func() { clearBtn := widget.NewButton("Clear", func() {
state.thumbFile = nil state.thumbnailFile = nil
state.thumbFiles = nil state.thumbnailFiles = nil
state.showThumbView() state.showThumbnailView()
}) })
clearBtn.Importance = widget.LowImportance clearBtn.Importance = widget.LowImportance
// Contact sheet checkbox (wrapped) // Contact sheet checkbox (wrapped)
contactSheetCheck := widget.NewCheck("", func(checked bool) { contactSheetCheck := widget.NewCheck("", func(checked bool) {
state.thumbContactSheet = checked state.thumbnailContactSheet = checked
state.persistThumbConfig() state.persistThumbnailConfig()
state.showThumbView() state.showThumbnailView()
}) })
contactSheetCheck.Checked = state.thumbContactSheet contactSheetCheck.Checked = state.thumbnailContactSheet
contactSheetLabel := widget.NewLabel("Generate Contact Sheet (single image)") contactSheetLabel := widget.NewLabel("Generate Contact Sheet (single image)")
contactSheetLabel.Wrapping = fyne.TextWrapWord contactSheetLabel.Wrapping = fyne.TextWrapWord
contactSheetToggle := ui.NewTappable(contactSheetLabel, func() { contactSheetToggle := ui.NewTappable(contactSheetLabel, func() {
@ -213,10 +213,10 @@ func buildThumbView(state *appState) fyne.CanvasObject {
contactSheetRow := container.NewBorder(nil, nil, contactSheetCheck, nil, contactSheetToggle) contactSheetRow := container.NewBorder(nil, nil, contactSheetCheck, nil, contactSheetToggle)
timestampCheck := widget.NewCheck("", func(checked bool) { timestampCheck := widget.NewCheck("", func(checked bool) {
state.thumbShowTimestamps = checked state.thumbnailShowTimestamps = checked
state.persistThumbConfig() state.persistThumbnailConfig()
}) })
timestampCheck.Checked = state.thumbShowTimestamps timestampCheck.Checked = state.thumbnailShowTimestamps
timestampLabel := widget.NewLabel("Show timestamps on thumbnails") timestampLabel := widget.NewLabel("Show timestamps on thumbnails")
timestampLabel.Wrapping = fyne.TextWrapWord timestampLabel.Wrapping = fyne.TextWrapWord
timestampToggle := ui.NewTappable(timestampLabel, func() { timestampToggle := ui.NewTappable(timestampLabel, func() {
@ -226,53 +226,53 @@ func buildThumbView(state *appState) fyne.CanvasObject {
// Conditional settings based on contact sheet mode // Conditional settings based on contact sheet mode
var settingsOptions fyne.CanvasObject var settingsOptions fyne.CanvasObject
if state.thumbContactSheet { if state.thumbnailContactSheet {
// Contact sheet mode: show columns and rows // Contact sheet mode: show columns and rows
colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbColumns)) colLabel := widget.NewLabel(fmt.Sprintf("Columns: %d", state.thumbnailColumns))
rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbRows)) rowLabel := widget.NewLabel(fmt.Sprintf("Rows: %d", state.thumbnailRows))
totalThumbs := state.thumbColumns * state.thumbRows totalThumbs := state.thumbnailColumns * state.thumbnailRows
totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs)) totalLabel := widget.NewLabel(fmt.Sprintf("Total thumbnails: %d", totalThumbs))
totalLabel.TextStyle = fyne.TextStyle{Italic: true} totalLabel.TextStyle = fyne.TextStyle{Italic: true}
totalLabel.Wrapping = fyne.TextWrapWord totalLabel.Wrapping = fyne.TextWrapWord
colSlider := widget.NewSlider(2, 9) colSlider := widget.NewSlider(2, 9)
colSlider.Value = float64(state.thumbColumns) colSlider.Value = float64(state.thumbnailColumns)
colSlider.Step = 1 colSlider.Step = 1
colSlider.OnChanged = func(val float64) { colSlider.OnChanged = func(val float64) {
state.thumbColumns = int(val) state.thumbnailColumns = int(val)
colLabel.SetText(fmt.Sprintf("Columns: %d", int(val))) colLabel.SetText(fmt.Sprintf("Columns: %d", int(val)))
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbnailColumns*state.thumbnailRows))
state.persistThumbConfig() state.persistThumbnailConfig()
} }
rowSlider := widget.NewSlider(2, 12) rowSlider := widget.NewSlider(2, 12)
rowSlider.Value = float64(state.thumbRows) rowSlider.Value = float64(state.thumbnailRows)
rowSlider.Step = 1 rowSlider.Step = 1
rowSlider.OnChanged = func(val float64) { rowSlider.OnChanged = func(val float64) {
state.thumbRows = int(val) state.thumbnailRows = int(val)
rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val))) rowLabel.SetText(fmt.Sprintf("Rows: %d", int(val)))
totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbColumns*state.thumbRows)) totalLabel.SetText(fmt.Sprintf("Total thumbnails: %d", state.thumbnailColumns*state.thumbnailRows))
state.persistThumbConfig() state.persistThumbnailConfig()
} }
sizeOptions := []string{"240 px", "300 px", "360 px", "420 px", "480 px"} sizeOptions := []string{"240 px", "300 px", "360 px", "420 px", "480 px"}
sizeSelect := widget.NewSelect(sizeOptions, func(val string) { sizeSelect := widget.NewSelect(sizeOptions, func(val string) {
switch val { switch val {
case "240 px": case "240 px":
state.thumbSheetWidth = 240 state.thumbnailSheetWidth = 240
case "300 px": case "300 px":
state.thumbSheetWidth = 300 state.thumbnailSheetWidth = 300
case "360 px": case "360 px":
state.thumbSheetWidth = 360 state.thumbnailSheetWidth = 360
case "420 px": case "420 px":
state.thumbSheetWidth = 420 state.thumbnailSheetWidth = 420
case "480 px": case "480 px":
state.thumbSheetWidth = 480 state.thumbnailSheetWidth = 480
} }
state.persistThumbConfig() state.persistThumbnailConfig()
}) })
switch state.thumbSheetWidth { switch state.thumbnailSheetWidth {
case 240: case 240:
sizeSelect.SetSelected("240 px") sizeSelect.SetSelected("240 px")
case 300: case 300:
@ -298,24 +298,24 @@ func buildThumbView(state *appState) fyne.CanvasObject {
) )
} else { } else {
// Individual thumbnails mode: show count and width // Individual thumbnails mode: show count and width
countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbCount)) countLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Count: %d", state.thumbnailCount))
countSlider := widget.NewSlider(3, 50) countSlider := widget.NewSlider(3, 50)
countSlider.Value = float64(state.thumbCount) countSlider.Value = float64(state.thumbnailCount)
countSlider.Step = 1 countSlider.Step = 1
countSlider.OnChanged = func(val float64) { countSlider.OnChanged = func(val float64) {
state.thumbCount = int(val) state.thumbnailCount = int(val)
countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val))) countLabel.SetText(fmt.Sprintf("Thumbnail Count: %d", int(val)))
state.persistThumbConfig() state.persistThumbnailConfig()
} }
widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbWidth)) widthLabel := widget.NewLabel(fmt.Sprintf("Thumbnail Width: %d px", state.thumbnailWidth))
widthSlider := widget.NewSlider(160, 640) widthSlider := widget.NewSlider(160, 640)
widthSlider.Value = float64(state.thumbWidth) widthSlider.Value = float64(state.thumbnailWidth)
widthSlider.Step = 32 widthSlider.Step = 32
widthSlider.OnChanged = func(val float64) { widthSlider.OnChanged = func(val float64) {
state.thumbWidth = int(val) state.thumbnailWidth = int(val)
widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val))) widthLabel.SetText(fmt.Sprintf("Thumbnail Width: %d px", int(val)))
state.persistThumbConfig() state.persistThumbnailConfig()
} }
settingsOptions = container.NewVBox( settingsOptions = container.NewVBox(
@ -330,12 +330,12 @@ func buildThumbView(state *appState) fyne.CanvasObject {
// Helper function to create thumbnail job // Helper function to create thumbnail job
createThumbJob := func() *queue.Job { createThumbJob := func() *queue.Job {
return state.createThumbJobForPath(state.thumbFile.Path) return state.createThumbnailJobForPath(state.thumbnailFile.Path)
} }
// Generate Now button - adds to queue and starts it // Generate Now button - adds to queue and starts it
generateNowBtn := widget.NewButton("GENERATE NOW", func() { generateNowBtn := widget.NewButton("GENERATE NOW", func() {
if state.thumbFile == nil { if state.thumbnailFile == nil {
dialog.ShowInformation("No Video", "Please load a video file first.", state.window) dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
return return
} }
@ -358,13 +358,13 @@ func buildThumbView(state *appState) fyne.CanvasObject {
}) })
generateNowBtn.Importance = widget.HighImportance generateNowBtn.Importance = widget.HighImportance
if state.thumbFile == nil { if state.thumbnailFile == nil {
generateNowBtn.Disable() generateNowBtn.Disable()
} }
// Add to Queue button // Add to Queue button
addQueueBtn := widget.NewButton("Add to Queue", func() { addQueueBtn := widget.NewButton("Add to Queue", func() {
if state.thumbFile == nil { if state.thumbnailFile == nil {
dialog.ShowInformation("No Video", "Please load a video file first.", state.window) dialog.ShowInformation("No Video", "Please load a video file first.", state.window)
return return
} }
@ -381,12 +381,12 @@ func buildThumbView(state *appState) fyne.CanvasObject {
}) })
addQueueBtn.Importance = widget.MediumImportance addQueueBtn.Importance = widget.MediumImportance
if state.thumbFile == nil { if state.thumbnailFile == nil {
addQueueBtn.Disable() addQueueBtn.Disable()
} }
addAllBtn := widget.NewButton("Add All to Queue", func() { addAllBtn := widget.NewButton("Add All to Queue", func() {
if len(state.thumbFiles) == 0 { if len(state.thumbnailFiles) == 0 {
dialog.ShowInformation("No Videos", "Load videos first to add to queue.", state.window) dialog.ShowInformation("No Videos", "Load videos first to add to queue.", state.window)
return return
} }
@ -394,13 +394,13 @@ func buildThumbView(state *appState) fyne.CanvasObject {
dialog.ShowInformation("Queue", "Queue not initialized.", state.window) dialog.ShowInformation("Queue", "Queue not initialized.", state.window)
return return
} }
for _, src := range state.thumbFiles { for _, src := range state.thumbnailFiles {
if src == nil || src.Path == "" { if src == nil || src.Path == "" {
continue continue
} }
state.jobQueue.Add(state.createThumbJobForPath(src.Path)) state.jobQueue.Add(state.createThumbnailJobForPath(src.Path))
} }
dialog.ShowInformation("Queue", fmt.Sprintf("Queued %d thumbnail jobs.", len(state.thumbFiles)), state.window) dialog.ShowInformation("Queue", fmt.Sprintf("Queued %d thumbnail jobs.", len(state.thumbnailFiles)), state.window)
}) })
addAllBtn.Importance = widget.MediumImportance addAllBtn.Importance = widget.MediumImportance
@ -412,20 +412,20 @@ func buildThumbView(state *appState) fyne.CanvasObject {
// View Results button - shows output folder if it exists // View Results button - shows output folder if it exists
viewResultsBtn := widget.NewButton("View Results", func() { viewResultsBtn := widget.NewButton("View Results", func() {
if state.thumbFile == nil { if state.thumbnailFile == nil {
dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window) dialog.ShowInformation("No Video", "Load a video first to locate results.", state.window)
return return
} }
videoDir := filepath.Dir(state.thumbFile.Path) videoDir := filepath.Dir(state.thumbnailFile.Path)
videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbFile.Path), filepath.Ext(state.thumbFile.Path)) videoBaseName := strings.TrimSuffix(filepath.Base(state.thumbnailFile.Path), filepath.Ext(state.thumbnailFile.Path))
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
if state.thumbContactSheet { if state.thumbnailContactSheet {
outputDir = videoDir outputDir = videoDir
} }
// If contact sheet mode, try to open contact sheet image // If contact sheet mode, try to open contact sheet image
if state.thumbContactSheet { if state.thumbnailContactSheet {
contactSheetPath := filepath.Join(outputDir, fmt.Sprintf("%s_contact_sheet.jpg", videoBaseName)) contactSheetPath := filepath.Join(outputDir, fmt.Sprintf("%s_contact_sheet.jpg", videoBaseName))
if _, err := os.Stat(contactSheetPath); err == nil { if _, err := os.Stat(contactSheetPath); err == nil {
if err := openFile(contactSheetPath); err != nil { if err := openFile(contactSheetPath); err != nil {
@ -452,9 +452,9 @@ func buildThumbView(state *appState) fyne.CanvasObject {
} }
// Otherwise, open first thumbnail // Otherwise, open first thumbnail
firstThumb := filepath.Join(outputDir, "thumb_0001.jpg") firstThumbnail := filepath.Join(outputDir, "thumbnail_0001.jpg")
if _, err := os.Stat(firstThumb); err == nil { if _, err := os.Stat(firstThumbnail); err == nil {
if err := openFile(firstThumb); err != nil { if err := openFile(firstThumbnail); err != nil {
dialog.ShowError(fmt.Errorf("failed to open thumbnail: %w", err), state.window) dialog.ShowError(fmt.Errorf("failed to open thumbnail: %w", err), state.window)
} }
return return
@ -466,7 +466,7 @@ func buildThumbView(state *appState) fyne.CanvasObject {
} }
}) })
viewResultsBtn.Importance = widget.MediumImportance viewResultsBtn.Importance = widget.MediumImportance
if state.thumbFile == nil { if state.thumbnailFile == nil {
viewResultsBtn.Disable() viewResultsBtn.Disable()
} }
@ -487,16 +487,16 @@ func buildThumbView(state *appState) fyne.CanvasObject {
// Main content - split layout with preview on left, settings on right // Main content - split layout with preview on left, settings on right
leftColumn := container.NewVBox(videoContainer) leftColumn := container.NewVBox(videoContainer)
if len(state.thumbFiles) > 1 { if len(state.thumbnailFiles) > 1 {
list := widget.NewList( list := widget.NewList(
func() int { return len(state.thumbFiles) }, func() int { return len(state.thumbnailFiles) },
func() fyne.CanvasObject { return widget.NewLabel("") }, func() fyne.CanvasObject { return widget.NewLabel("") },
func(i widget.ListItemID, o fyne.CanvasObject) { func(i widget.ListItemID, o fyne.CanvasObject) {
if i < 0 || i >= len(state.thumbFiles) { if i < 0 || i >= len(state.thumbnailFiles) {
return return
} }
label := o.(*widget.Label) label := o.(*widget.Label)
src := state.thumbFiles[i] src := state.thumbnailFiles[i]
if src == nil { if src == nil {
label.SetText("") label.SetText("")
return return
@ -505,16 +505,16 @@ func buildThumbView(state *appState) fyne.CanvasObject {
}, },
) )
list.OnSelected = func(id widget.ListItemID) { list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(state.thumbFiles) { if id < 0 || id >= len(state.thumbnailFiles) {
return return
} }
state.thumbFile = state.thumbFiles[id] state.thumbnailFile = state.thumbnailFiles[id]
state.loadThumbSourceAtIndex(id) state.loadThumbnailSourceAtIndex(id)
state.showThumbView() state.showThumbnailView()
} }
if state.thumbFile != nil { if state.thumbnailFile != nil {
for i, src := range state.thumbFiles { for i, src := range state.thumbnailFiles {
if src != nil && src.Path == state.thumbFile.Path { if src != nil && src.Path == state.thumbnailFile.Path {
list.Select(i) list.Select(i)
break break
} }
@ -543,7 +543,7 @@ func buildThumbView(state *appState) fyne.CanvasObject {
return container.NewBorder(topBar, bottomBar, nil, nil, content) return container.NewBorder(topBar, bottomBar, nil, nil, content)
} }
func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { func (s *appState) executeThumbnailJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
cfg := job.Config cfg := job.Config
inputPath := cfg["inputPath"].(string) inputPath := cfg["inputPath"].(string)
outputDir := cfg["outputDir"].(string) outputDir := cfg["outputDir"].(string)
@ -611,30 +611,30 @@ func (s *appState) executeThumbJob(ctx context.Context, job *queue.Job, progress
return nil return nil
} }
func (s *appState) createThumbJobForPath(path string) *queue.Job { func (s *appState) createThumbnailJobForPath(path string) *queue.Job {
videoDir := filepath.Dir(path) videoDir := filepath.Dir(path)
videoBaseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) videoBaseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName)) outputDir := filepath.Join(videoDir, fmt.Sprintf("%s_thumbnails", videoBaseName))
outputFile := outputDir outputFile := outputDir
if s.thumbContactSheet { if s.thumbnailContactSheet {
outputDir = videoDir outputDir = videoDir
outputFile = filepath.Join(videoDir, fmt.Sprintf("%s_contact_sheet.jpg", videoBaseName)) outputFile = filepath.Join(videoDir, fmt.Sprintf("%s_contact_sheet.jpg", videoBaseName))
} }
var count, width int var count, width int
var description string var description string
if s.thumbContactSheet { if s.thumbnailContactSheet {
count = s.thumbColumns * s.thumbRows count = s.thumbnailColumns * s.thumbnailRows
width = s.thumbSheetWidth width = s.thumbnailSheetWidth
description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", s.thumbColumns, s.thumbRows, count) description = fmt.Sprintf("Contact sheet: %dx%d grid (%d thumbnails)", s.thumbnailColumns, s.thumbnailRows, count)
} else { } else {
count = s.thumbCount count = s.thumbnailCount
width = s.thumbWidth width = s.thumbnailWidth
description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width) description = fmt.Sprintf("%d individual thumbnails (%dpx width)", count, width)
} }
return &queue.Job{ return &queue.Job{
Type: queue.JobTypeThumb, Type: queue.JobTypeThumbnail,
Title: filepath.Base(path), Title: filepath.Base(path),
Description: description, Description: description,
InputFile: path, InputFile: path,
@ -644,10 +644,10 @@ func (s *appState) createThumbJobForPath(path string) *queue.Job {
"outputDir": outputDir, "outputDir": outputDir,
"count": float64(count), "count": float64(count),
"width": float64(width), "width": float64(width),
"contactSheet": s.thumbContactSheet, "contactSheet": s.thumbnailContactSheet,
"showTimestamp": s.thumbShowTimestamps, "showTimestamp": s.thumbnailShowTimestamps,
"columns": float64(s.thumbColumns), "columns": float64(s.thumbnailColumns),
"rows": float64(s.thumbRows), "rows": float64(s.thumbnailRows),
}, },
} }
} }