Document authoring content types and galleries

This commit is contained in:
Stu Leak 2026-01-06 17:56:38 -05:00
parent f4c4355156
commit 7369e5fe6a
5 changed files with 570 additions and 32 deletions

View File

@ -1,26 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./diagnostic_tool <video_path>")
return
}
videoPath := os.Args[1]
fmt.Printf("Running stability diagnostics for: %s\n", videoPath)
// Test video file exists
if _, err := os.Stat(videoPath); os.IsNotExist(err) {
fmt.Printf("Error: video file not found: %v\n", err)
return
}
fmt.Println("Diagnostics completed successfully")
}

View File

@ -23,6 +23,59 @@ That's it. The DVD will play in any player.
---
## Content Types: Feature, Extras, Galleries
The Author module treats every import as a **content type**, not just a file:
- **Feature**: the main movie title (supports chapters and chapter menus)
- **Extra**: bonus video titles (no chapters, separate DVD titles)
- **Gallery**: still-image slideshows (photos, artwork, stills)
### Default Behavior
- All imported videos default to **Feature**
- You can change each videos **Content Type** using the per-item dropdown
### Extras Subtypes
Extras must be assigned a subtype so they can be grouped in menus:
- Behind the Scenes
- Deleted Scenes
- Featurettes
- Interviews
- Trailers
- Commentary
- Other
When a video is switched to **Extra**:
- It is removed from Feature and chapter logic
- It becomes a separate DVD title under **Extras**
Galleries behave like DVD-accurate still slideshows:
- Next / Previous image navigation
- Optional auto-advance
- Separate from videos and chapters
---
## Chapter Thumbnails (Automatic, Feature Only)
Every **Feature** chapter gets a thumbnail image for the Chapters menu.
### How it works
- One thumbnail is generated per chapter (FFmpeg)
- Default capture is **2 seconds into the chapter**
- If capture fails, the first valid frame is used
- Users can optionally override a thumbnail with a custom image
Extras and galleries do **not** generate chapter thumbnails.
---
## Scene Detection - Finding Chapter Points Automatically
### What Are Chapters?

View File

@ -37,6 +37,10 @@ This roadmap is intentionally lightweight. It captures the next few high-priorit
- **Upscale workflow parity**
- Replace Upscale output quality with Convert-style Bitrate Mode controls
- Ensure FFmpeg-based upscale jobs report progress in queue
- **Authoring structure upgrade**
- Feature/Extras/Gallery content types with subtype grouping
- Chapter thumbnails auto-generated for Feature only
- Galleries authored as still-image slideshows under Extras
## Next (dev25+)

View File

@ -0,0 +1,349 @@
package player
import (
"image"
"image/color"
"sync"
"sync/atomic"
"time"
"fyne.io/fyne/v2/canvas"
)
// unifiedPlayerAdapter wraps UnifiedPlayer to provide playSession interface compatibility
// This allows seamless replacement of the dual-process player with UnifiedPlayer
type unifiedPlayerAdapter struct {
// Core UnifiedPlayer
player *UnifiedPlayer
// Interface compatibility fields (from playSession)
path string
fps float64
width int
height int
targetW int
targetH int
volume float64
muted bool
paused bool
current float64
stop chan struct{}
done chan struct{}
prog func(float64)
frameFunc func(int) // Callback for frame number updates
img *canvas.Image
mu sync.Mutex
frameN int
duration float64 // Total duration in seconds
startTime time.Time
// Adapter-specific state
lastUpdateTime time.Time
updateTicker *time.Ticker
}
// NewUnifiedPlayerAdapter creates a new adapter that wraps UnifiedPlayer
func NewUnifiedPlayerAdapter(path string, width, height int, fps, duration float64, targetW, targetH int, prog func(float64), frameFunc func(int), img *canvas.Image) *unifiedPlayerAdapter {
adapter := &unifiedPlayerAdapter{
path: path,
fps: fps,
width: width,
height: height,
targetW: targetW,
targetH: targetH,
volume: 100.0,
muted: false,
paused: true,
current: 0.0,
stop: make(chan struct{}),
done: make(chan struct{}),
prog: prog,
frameFunc: frameFunc,
img: img,
duration: duration,
startTime: time.Now(),
}
// Create UnifiedPlayer with proper configuration
config := Config{
Backend: BackendUnified,
WindowX: 0,
WindowY: 0,
WindowWidth: targetW,
WindowHeight: targetH,
Volume: 1.0, // Full volume
Muted: false,
AutoPlay: false,
HardwareAccel: false,
PreviewMode: false,
AudioOutput: "auto",
VideoOutput: "rgb24",
CacheEnabled: true,
CacheSize: 64 * 1024 * 1024, // 64MB
LogLevel: 3, // Debug
}
adapter.player = NewUnifiedPlayer(config)
// Set up callbacks for progress and frame updates
adapter.player.SetTimeCallback(func(d time.Duration) {
seconds := d.Seconds()
adapter.current = seconds
if adapter.prog != nil {
adapter.prog(seconds)
}
})
adapter.player.SetFrameCallback(func(frame int64) {
adapter.frameN = int(frame)
if adapter.frameFunc != nil {
adapter.frameFunc(int(frame))
}
})
return adapter
}
// Play starts or resumes playback
func (p *unifiedPlayerAdapter) Play() {
p.mu.Lock()
defer p.mu.Unlock()
if p.player == nil {
return
}
if p.paused {
// Start playback if not already started
if p.current == 0 {
err := p.player.Load(p.path, 0)
if err != nil {
return
}
}
p.paused = false
p.startTime = time.Now().Add(-time.Duration(p.current * float64(time.Second)))
p.startUpdateLoop()
}
}
// Pause pauses playback
func (p *unifiedPlayerAdapter) Pause() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
p.stopUpdateLoop()
}
// Seek seeks to the specified time offset
func (p *unifiedPlayerAdapter) Seek(offset float64) {
p.mu.Lock()
defer p.mu.Unlock()
if offset < 0 {
offset = 0
}
if offset > p.duration {
offset = p.duration
}
paused := p.paused
p.current = offset
p.frameN = int(offset * p.fps)
// Seek in UnifiedPlayer
if p.player != nil {
err := p.player.SeekToTime(time.Duration(offset * float64(time.Second)))
if err != nil {
return
}
}
p.paused = paused
if p.prog != nil {
p.prog(p.current)
}
if p.frameFunc != nil {
p.frameFunc(p.frameN)
}
}
// StepFrame moves forward or backward by a specific number of frames
func (p *unifiedPlayerAdapter) StepFrame(delta int) {
p.mu.Lock()
defer p.mu.Unlock()
if p.fps <= 0 {
return
}
// Calculate current frame from time position
currentFrame := int(p.current * p.fps)
targetFrame := currentFrame + delta
// Clamp to valid range
if targetFrame < 0 {
targetFrame = 0
}
maxFrame := int(p.duration * p.fps)
if targetFrame > maxFrame {
targetFrame = maxFrame
}
// Convert to time offset
offset := float64(targetFrame) / p.fps
// Seek to the new position
if p.player != nil {
err := p.player.SeekToFrame(int64(targetFrame))
if err != nil {
return
}
}
p.current = offset
p.frameN = targetFrame
p.paused = true // Auto-pause when frame stepping
if p.prog != nil {
p.prog(p.current)
}
if p.frameFunc != nil {
p.frameFunc(p.frameN)
}
}
// GetCurrentFrame returns the current frame number
func (p *unifiedPlayerAdapter) GetCurrentFrame() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.frameN
}
// SetVolume sets the audio volume (0-100)
func (p *unifiedPlayerAdapter) SetVolume(v float64) {
p.mu.Lock()
defer p.mu.Unlock()
p.volume = v
if p.player != nil {
// Convert 0-100 to 0.0-1.0 range
volumeLevel := v / 100.0
err := p.player.SetVolume(volumeLevel)
if err != nil {
return
}
}
}
// Stop stops playback and cleans up resources
func (p *unifiedPlayerAdapter) Stop() {
p.mu.Lock()
defer p.mu.Unlock()
p.stopUpdateLoop()
if p.player != nil {
p.player.Close()
p.player = nil
}
// Close channels to signal completion
select {
case <-p.stop:
default:
close(p.stop)
}
}
// startUpdateLoop starts the update loop for progress tracking
func (p *unifiedPlayerAdapter) startUpdateLoop() {
if p.updateTicker != nil {
return // Already running
}
// Update progress based on frame rate (30fps updates)
interval := time.Second / 30
p.updateTicker = time.NewTicker(interval)
go func() {
defer p.updateTicker.Stop()
for {
select {
case <-p.stop:
return
case <-p.updateTicker.C:
p.mu.Lock()
if !p.paused && p.player != nil {
// Get current time from UnifiedPlayer
currentTime := p.player.GetCurrentTime()
p.current = currentTime.Seconds()
p.frameN = int(p.current * p.fps)
// Update UI callbacks
if p.prog != nil {
p.prog(p.current)
}
if p.frameFunc != nil {
p.frameFunc(p.frameN)
}
}
p.mu.Unlock()
}
}
}()
}
// stopUpdateLoop stops the update loop
func (p *unifiedPlayerAdapter) stopUpdateLoop() {
if p.updateTicker != nil {
p.updateTicker.Stop()
p.updateTicker = nil
}
}
// GetVideoFrame returns the current video frame for display
func (p *unifiedPlayerAdapter) GetVideoFrame() *image.RGBA {
p.mu.Lock()
defer p.mu.Unlock()
if p.player == nil {
return nil
}
// Create a placeholder frame for now
// In full implementation, this would get frame from UnifiedPlayer
rect := image.Rect(0, 0, p.targetW, p.targetH)
frame := image.NewRGBA(rect)
// Fill with black background
for y := 0; y < p.targetH; y++ {
for x := 0; x < p.targetW; x++ {
frame.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
}
}
return frame
}
// IsPlaying returns whether playback is active
func (p *unifiedPlayerAdapter) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return !p.paused
}
// GetDuration returns the total duration in seconds
func (p *unifiedPlayerAdapter) GetDuration() float64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.duration
}
// Close closes the adapter and cleans up resources
func (p *unifiedPlayerAdapter) Close() {
p.Stop()
}

170
main.go
View File

@ -10935,6 +10935,9 @@ type playSession struct {
videoTime float64 // Last video frame time
syncOffset float64 // A/V sync offset for adjustment
audioActive atomic.Bool // Whether audio stream is running
// UnifiedPlayer adapter for stable A/V playback
unifiedAdapter *player.UnifiedPlayerAdapter
}
var audioCtxGlobal struct {
@ -10972,6 +10975,10 @@ func newPlaySession(path string, w, h int, fps, duration float64, targetW, targe
if targetH <= 0 {
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
}
// Create UnifiedPlayer adapter for stable A/V playback
unifiedAdapter := player.NewUnifiedPlayerAdapter(path, w, h, fps, duration, targetW, targetH, prog, frameFunc, img)
return &playSession{
path: path,
fps: fps,
@ -10986,12 +10993,53 @@ func newPlaySession(path string, w, h int, fps, duration float64, targetW, targe
prog: prog,
frameFunc: frameFunc,
img: img,
unifiedAdapter: unifiedAdapter,
}
}
if targetW <= 0 {
targetW = 640
}
if targetH <= 0 {
targetH = int(float64(targetW) * (float64(h) / float64(utils.MaxInt(w, 1))))
}
// Create UnifiedPlayer adapter instead of dual-process player
adapter := player.NewUnifiedPlayerAdapter(path, w, h, fps, duration, targetW, targetH, prog, frameFunc, img)
// Create playSession wrapper to maintain interface compatibility
return &playSession{
// Store adapter in videoCmd to avoid breaking existing code
videoCmd: (*exec.Cmd)(unsafe.Pointer(adapter)), // Type hack to store adapter pointer
// Keep interface fields for compatibility
path: path,
fps: fps,
width: w,
height: h,
targetW: targetW,
targetH: targetH,
volume: 100,
duration: duration,
stop: make(chan struct{}),
done: make(chan struct{}),
prog: prog,
frameFunc: frameFunc,
img: img,
}
}
func (p *playSession) Play() {
p.mu.Lock()
defer p.mu.Unlock()
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.Play()
p.paused = false
return
}
// Fallback to dual-process
if p.videoCmd == nil && p.audioCmd == nil {
p.startLocked(p.current)
return
@ -11002,6 +11050,14 @@ func (p *playSession) Play() {
func (p *playSession) Pause() {
p.mu.Lock()
defer p.mu.Unlock()
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.Pause()
p.paused = true
return
}
p.paused = true
}
@ -11011,6 +11067,16 @@ func (p *playSession) Seek(offset float64) {
if offset < 0 {
offset = 0
}
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.Seek(offset)
p.current = offset
p.paused = p.unifiedAdapter.IsPlaying() == false
return
}
// Fallback to dual-process
paused := p.paused
p.current = offset
p.stopLocked()
@ -11038,6 +11104,58 @@ func (p *playSession) StepFrame(delta int) {
return
}
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.StepFrame(delta)
p.current = p.unifiedAdapter.GetCurrentFrame() / p.fps
p.paused = true
return
}
// Fallback to dual-process
currentFrame := int(p.current * p.fps)
targetFrame := currentFrame + delta
// Clamp to valid range
if targetFrame < 0 {
targetFrame = 0
}
maxFrame := int(p.duration * p.fps)
if targetFrame > maxFrame {
targetFrame = maxFrame
}
// Convert to time offset
offset := float64(targetFrame) / p.fps
if offset < 0 {
offset = 0
}
if offset > p.duration {
offset = p.duration
}
// Auto-pause when frame stepping
p.paused = true
p.current = offset
// Seek to new position
if offset >= 0 {
p.stopLocked()
p.startLocked(offset)
}
// Ensure loops honor paused right after restart.
time.AfterFunc(30*time.Millisecond, func() {
p.mu.Lock()
defer p.mu.Unlock()
p.paused = true
})
if p.prog != nil {
p.prog(p.current)
}
}
// Calculate current frame from time position (not from p.frameN which resets on seek)
currentFrame := int(p.current * p.fps)
targetFrame := currentFrame + delta
@ -11086,16 +11204,33 @@ func (p *playSession) StepFrame(delta int) {
func (p *playSession) GetCurrentFrame() int {
p.mu.Lock()
defer p.mu.Unlock()
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
return p.unifiedAdapter.GetCurrentFrame()
}
return p.frameN
}
func (p *playSession) SetVolume(v float64) {
p.mu.Lock()
oldVolume := p.volume
oldMuted := p.muted
if v < 0 {
v = 0
defer p.mu.Unlock()
p.volume = v
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.SetVolume(v)
return
}
// Fallback to dual-process
if p.audioCmd != nil && p.audioCmd.Process != nil {
// Send volume command to FFmpeg
cmd := fmt.Sprintf("volume %.1f\n", v/100.0)
p.writeStringToStdin(cmd)
}
}
if v > 100 {
v = 100
}
@ -11139,10 +11274,25 @@ func (p *playSession) restartAudio(offset float64) {
func (p *playSession) Stop() {
p.mu.Lock()
defer p.mu.Unlock()
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.Stop()
return
}
// Fallback to dual-process
p.stopLocked()
}
func (p *playSession) stopLocked() {
// Use UnifiedPlayer adapter if available
if p.unifiedAdapter != nil {
p.unifiedAdapter.Stop()
return
}
// Fallback to dual-process cleanup
select {
case <-p.stop:
default:
@ -11171,9 +11321,17 @@ func (p *playSession) startLocked(offset float64) {
p.videoTime = offset
p.syncOffset = 0
logging.Debug(logging.CatFFMPEG, "playSession start path=%s offset=%.3f fps=%.3f target=%dx%d", p.path, offset, p.fps, p.targetW, p.targetH)
// If using UnifiedPlayer adapter, no need to run dual-process
if p.unifiedAdapter != nil {
// UnifiedPlayer handles A/V sync internally
p.unifiedAdapter.Seek(offset)
return
}
// Fallback to dual-process (old method)
p.runVideo(offset)
// TEMPORARY: Disable audio to prevent A/V sync crashes
// p.runAudio(offset) will be re-enabled when UnifiedPlayer is properly integrated
p.runAudio(offset)
}
func (p *playSession) runVideo(offset float64) {