forked from Leak_Technologies/VideoTools
Add comprehensive DVD-Video encoding functionality:
- New internal/convert package with modular architecture
- types.go: Core types (VideoSource, ConvertConfig, FormatOption)
- ffmpeg.go: FFmpeg codec mapping and video probing
- presets.go: Output format definitions
- dvd.go: NTSC-specific DVD encoding and validation
- dvd_regions.go: PAL, SECAM, and multi-region support
- New internal/app/dvd_adapter.go for main.go integration
Features implemented:
✓ DVD-NTSC preset (720×480@29.97fps, MPEG-2/AC-3)
✓ Multi-region support (NTSC, PAL, SECAM - all region-free)
✓ Comprehensive validation system with actionable warnings
✓ Automatic framerate conversion (23.976p, 24p, 30p, 60p)
✓ Audio resampling to 48 kHz
✓ Aspect ratio handling (4:3, 16:9, letterboxing)
✓ Interlacing detection and preservation
✓ DVDStyler-compatible output (no re-encoding)
✓ PS2-safe bitrate limits (max 9000 kbps)
Complete technical specifications and integration guide in:
- DVD_IMPLEMENTATION_SUMMARY.md
All packages compile without errors or warnings.
Ready for integration with existing queue and UI systems.
🤖 Generated with Claude Code
212 lines
5.3 KiB
Go
212 lines
5.3 KiB
Go
package convert
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/logging"
|
|
"git.leaktechnologies.dev/stu/VideoTools/internal/utils"
|
|
)
|
|
|
|
// CRFForQuality returns the CRF value for a given quality preset
|
|
func CRFForQuality(q string) string {
|
|
switch q {
|
|
case "Draft (CRF 28)":
|
|
return "28"
|
|
case "High (CRF 18)":
|
|
return "18"
|
|
case "Lossless":
|
|
return "0"
|
|
default:
|
|
return "23"
|
|
}
|
|
}
|
|
|
|
// DetermineVideoCodec maps user-friendly codec names to FFmpeg codec names
|
|
func DetermineVideoCodec(cfg ConvertConfig) string {
|
|
switch cfg.VideoCodec {
|
|
case "H.264":
|
|
if cfg.HardwareAccel == "nvenc" {
|
|
return "h264_nvenc"
|
|
} else if cfg.HardwareAccel == "qsv" {
|
|
return "h264_qsv"
|
|
} else if cfg.HardwareAccel == "videotoolbox" {
|
|
return "h264_videotoolbox"
|
|
}
|
|
return "libx264"
|
|
case "H.265":
|
|
if cfg.HardwareAccel == "nvenc" {
|
|
return "hevc_nvenc"
|
|
} else if cfg.HardwareAccel == "qsv" {
|
|
return "hevc_qsv"
|
|
} else if cfg.HardwareAccel == "videotoolbox" {
|
|
return "hevc_videotoolbox"
|
|
}
|
|
return "libx265"
|
|
case "VP9":
|
|
return "libvpx-vp9"
|
|
case "AV1":
|
|
return "libaom-av1"
|
|
case "Copy":
|
|
return "copy"
|
|
default:
|
|
return "libx264"
|
|
}
|
|
}
|
|
|
|
// DetermineAudioCodec maps user-friendly codec names to FFmpeg codec names
|
|
func DetermineAudioCodec(cfg ConvertConfig) string {
|
|
switch cfg.AudioCodec {
|
|
case "AAC":
|
|
return "aac"
|
|
case "Opus":
|
|
return "libopus"
|
|
case "MP3":
|
|
return "libmp3lame"
|
|
case "FLAC":
|
|
return "flac"
|
|
case "Copy":
|
|
return "copy"
|
|
default:
|
|
return "aac"
|
|
}
|
|
}
|
|
|
|
// ProbeVideo uses ffprobe to extract metadata from a video file
|
|
func ProbeVideo(path string) (*VideoSource, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "ffprobe",
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
path,
|
|
)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result struct {
|
|
Format struct {
|
|
Filename string `json:"filename"`
|
|
Format string `json:"format_long_name"`
|
|
Duration string `json:"duration"`
|
|
FormatName string `json:"format_name"`
|
|
BitRate string `json:"bit_rate"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
Index int `json:"index"`
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Duration string `json:"duration"`
|
|
BitRate string `json:"bit_rate"`
|
|
PixFmt string `json:"pix_fmt"`
|
|
SampleRate string `json:"sample_rate"`
|
|
Channels int `json:"channels"`
|
|
AvgFrameRate string `json:"avg_frame_rate"`
|
|
FieldOrder string `json:"field_order"`
|
|
Disposition struct {
|
|
AttachedPic int `json:"attached_pic"`
|
|
} `json:"disposition"`
|
|
} `json:"streams"`
|
|
}
|
|
if err := json.Unmarshal(out, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
src := &VideoSource{
|
|
Path: path,
|
|
DisplayName: filepath.Base(path),
|
|
Format: utils.FirstNonEmpty(result.Format.Format, result.Format.FormatName),
|
|
}
|
|
if rate, err := utils.ParseInt(result.Format.BitRate); err == nil {
|
|
src.Bitrate = rate
|
|
}
|
|
if durStr := result.Format.Duration; durStr != "" {
|
|
if val, err := utils.ParseFloat(durStr); err == nil {
|
|
src.Duration = val
|
|
}
|
|
}
|
|
// Track if we've found the main video stream (not cover art)
|
|
foundMainVideo := false
|
|
var coverArtStreamIndex int = -1
|
|
|
|
for _, stream := range result.Streams {
|
|
switch stream.CodecType {
|
|
case "video":
|
|
// Check if this is an attached picture (cover art)
|
|
if stream.Disposition.AttachedPic == 1 {
|
|
coverArtStreamIndex = stream.Index
|
|
logging.Debug(logging.CatFFMPEG, "found embedded cover art at stream %d", stream.Index)
|
|
continue
|
|
}
|
|
// Only use the first non-cover-art video stream
|
|
if !foundMainVideo {
|
|
foundMainVideo = true
|
|
src.VideoCodec = stream.CodecName
|
|
src.FieldOrder = stream.FieldOrder
|
|
if stream.Width > 0 {
|
|
src.Width = stream.Width
|
|
}
|
|
if stream.Height > 0 {
|
|
src.Height = stream.Height
|
|
}
|
|
if dur, err := utils.ParseFloat(stream.Duration); err == nil && dur > 0 {
|
|
src.Duration = dur
|
|
}
|
|
if fr := utils.ParseFraction(stream.AvgFrameRate); fr > 0 {
|
|
src.FrameRate = fr
|
|
}
|
|
if stream.PixFmt != "" {
|
|
src.PixelFormat = stream.PixFmt
|
|
}
|
|
}
|
|
if src.Bitrate == 0 {
|
|
if br, err := utils.ParseInt(stream.BitRate); err == nil {
|
|
src.Bitrate = br
|
|
}
|
|
}
|
|
case "audio":
|
|
if src.AudioCodec == "" {
|
|
src.AudioCodec = stream.CodecName
|
|
if rate, err := utils.ParseInt(stream.SampleRate); err == nil {
|
|
src.AudioRate = rate
|
|
}
|
|
if stream.Channels > 0 {
|
|
src.Channels = stream.Channels
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract embedded cover art if present
|
|
if coverArtStreamIndex >= 0 {
|
|
coverPath := filepath.Join(os.TempDir(), fmt.Sprintf("videotools-embedded-cover-%d.png", time.Now().UnixNano()))
|
|
extractCmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-i", path,
|
|
"-map", fmt.Sprintf("0:%d", coverArtStreamIndex),
|
|
"-frames:v", "1",
|
|
"-y",
|
|
coverPath,
|
|
)
|
|
if err := extractCmd.Run(); err != nil {
|
|
logging.Debug(logging.CatFFMPEG, "failed to extract embedded cover art: %v", err)
|
|
} else {
|
|
src.EmbeddedCoverArt = coverPath
|
|
logging.Debug(logging.CatFFMPEG, "extracted embedded cover art to %s", coverPath)
|
|
}
|
|
}
|
|
|
|
return src, nil
|
|
}
|