diff --git a/.opencode/plans/videotools-localization-policy.md b/.opencode/plans/videotools-localization-policy.md new file mode 100644 index 0000000..9821414 --- /dev/null +++ b/.opencode/plans/videotools-localization-policy.md @@ -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. \ No newline at end of file diff --git a/author_module.go b/author_module.go index 03b0a25..ec8f5d6 100644 --- a/author_module.go +++ b/author_module.go @@ -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, diff --git a/docs/QUEUE_SYSTEM_GUIDE.md b/docs/QUEUE_SYSTEM_GUIDE.md index 932eb1e..f86b910 100644 --- a/docs/QUEUE_SYSTEM_GUIDE.md +++ b/docs/QUEUE_SYSTEM_GUIDE.md @@ -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 ) ``` diff --git a/internal/modules/handlers.go b/internal/modules/handlers.go index 0cd9093..81af704 100644 --- a/internal/modules/handlers.go +++ b/internal/modules/handlers.go @@ -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 diff --git a/internal/queue/queue.go b/internal/queue/queue.go index f793ce8..b72488c 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -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" diff --git a/internal/thumbnail/generator.go b/internal/thumbnail/generator.go index 8fd8aef..bb51fb2 100644 --- a/internal/thumbnail/generator.go +++ b/internal/thumbnail/generator.go @@ -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{ diff --git a/internal/ui/components.go b/internal/ui/components.go index 427d695..682994f 100644 --- a/internal/ui/components.go +++ b/internal/ui/components.go @@ -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" diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go index 981ae60..b94dbb6 100644 --- a/internal/ui/queueview.go +++ b/internal/ui/queueview.go @@ -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) diff --git a/main.go b/main.go index 63d05e0..5ec5cc7 100644 --- a/main.go +++ b/main.go @@ -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 ") fmt.Println(" videotools upscale ") fmt.Println(" videotools audio ") - fmt.Println(" videotools thumb ") + fmt.Println(" videotools thumbnail ") fmt.Println(" videotools compare ") fmt.Println(" videotools inspect ") 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 { diff --git a/thumb_config.go b/thumbnail_config.go similarity index 54% rename from thumb_config.go rename to thumbnail_config.go index 76eaa6b..2757217 100644 --- a/thumb_config.go +++ b/thumbnail_config.go @@ -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) } } diff --git a/thumb_module.go b/thumbnail_module.go similarity index 74% rename from thumb_module.go rename to thumbnail_module.go index f63ad45..222e5ab 100644 --- a/thumb_module.go +++ b/thumbnail_module.go @@ -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), }, } }