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

View File

@ -34,7 +34,7 @@ const (
JobTypeFilter JobType = "filter" // Effects/filters
JobTypeUpscale JobType = "upscale" // Video enhancement
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)
}
// HandleThumb handles the thumb module
func HandleThumb(files []string) {
logging.Debug(logging.CatModule, "thumb handler invoked with %v", files)
fmt.Println("thumb", files)
// HandleThumbnail handles the thumbnail module
func HandleThumbnail(files []string) {
logging.Debug(logging.CatModule, "thumbnail handler invoked with %v", files)
fmt.Println("thumbnail", files)
}
// HandleInspect handles the inspect module

View File

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

View File

@ -329,7 +329,7 @@ func (g *Generator) generateIndividual(ctx context.Context, config Config, durat
// Generate each thumbnail
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
args := []string{

View File

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

View File

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

View File

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