diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5de72aec..70db4652 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -114,7 +114,32 @@ "Bash(gh api:*)", "Bash(git:*)", "WebFetch(domain:docs.nvidia.com)", - "Bash(gh discussion:*)" + "Bash(gh discussion:*)", + "Bash(rtk git:*)", + "Bash(rtk uv:*)", + "Bash(rtk gh:*)", + "WebFetch(domain:github-production-user-asset-6210df.s3.amazonaws.com)", + "Bash(curl -sL \"https://raw.githubusercontent.com/rigaya/NVEnc/master/NVEncC_Options.en.md\")", + "Bash(curl -sL \"https://raw.githubusercontent.com/rigaya/QSVEnc/master/QSVEncC_Options.en.md\")", + "Bash(curl -sL \"https://raw.githubusercontent.com/rigaya/VCEEnc/master/VCEEncC_Options.en.md\")", + "mcp__claude_ai_Excalidraw__read_me", + "mcp__claude_ai_Excalidraw__create_view", + "Bash(QT_QPA_PLATFORM=offscreen rtk uv:*)", + "Bash(SKIP_NVIDIA=1 SKIP_INTEL=1 SKIP_AMD=1 uv run:*)", + "Bash([ -f \"/c/Users/Chris/Projects/FastFlix/fastflix/encoders/$encoder/command_builder.py\" ])", + "Bash(uv sync:*)", + "Bash(wc -l fastflix/**/*.py fastflix/*.py)", + "Bash(go version:*)", + "PowerShell(Get-Command:*)", + "PowerShell(Test-Path:*)", + "Bash(export PATH=\"/c/Program Files/Go/bin:$PATH\")", + "Bash(go mod:*)", + "Bash(C:/Users/Chris/Projects/FastFlix/dist/FastFlix/FastFlix.exe --version)", + "Bash(C:/Users/Chris/Projects/FastFlix/dist/FastFlix/FastFlix.exe --test)", + "Bash(du -sh *.dll *.pyd)", + "Read(//c/Users/Chris/Projects/FastFlix/dist/FastFlix/lib/PySide6/**)", + "Bash(du -sh Qt6WebEngine* Qt6Quick* Qt6Qml* Qt6Designer* Qt63D* Qt6Pdf* Qt6Multimedia* opengl32sw.dll)", + "Bash(go build:*)" ] } } diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f45a8437..0e22c01f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -48,4 +48,5 @@ jobs: - name: Run tests env: PYTEST_QT_API: pyside6 + QT_QPA_PLATFORM: offscreen run: uv run pytest tests -v diff --git a/.gitignore b/.gitignore index 07eb55e2..dd333c77 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,12 @@ build-dir/ benchmarking/ ./.claude/settings.local.json + +# Go installer build artifacts +cmd/installer/fastflix_dist.* +cmd/*/*.syso +cmd/installer/licenses.txt +cmd/installer/terms_translations.json + +# Build script cache (downloaded Python embeddable, get-pip.py) +scripts/cache/ diff --git a/CHANGES b/CHANGES index 407c2d1d..fca749f1 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,44 @@ # Changelog +## Version 6.3.0 + +* Adding #202 new video filters to the Advanced panel: vibrance, color temperature, curves presets, colorbalance presets, unsharp mask, deflicker, pad/letterbox, LUT3D file loading, and nlmeans_opencl GPU-accelerated denoise — with rigaya hardware encoder support for curves (--vpp-curves), unsharp (--vpp-unsharp), pad (--vpp-pad), LUT3D (--vpp-colorspace lut3d), and nlmeans_opencl (--vpp-nlmeans) +* Adding #212 full source and output file path tooltip on hover for queue items, with responsive text eliding for long file names (thanks to bmcassagne) +* Adding #344 lossless encoding option for AVC (x264), AV1 (AOM), VP9, and AV1 (SVT-AV1) encoders +* Adding #377 #550 #694 gamma and hue options to the Equalizer in the Advanced panel (FFmpeg eq/hue filters for software encoders, --vpp-tweak for rigaya) (thanks to lincsat, DCNerds, and Patrick Isbendjian) +* Adding #405 sharpen filter (CAS) to the Advanced panel (FFmpeg cas filter for software encoders, --vpp-unsharp for rigaya) +* Adding #447 GOP length setting to the Advanced panel (FFmpeg -g, rigaya --gop-len) (thanks to colemar) +* Adding #457 QSVEncC --tune parameter support (hq, ll, ull, lossless) for HEVC, AV1, and AVC encoders (thanks to Marco Ravich) +* Adding #480 2-pass CRF encoding for the AOM AV1 encoder for improved quality (thanks to jimbow973) +* Adding #498 deinterlace method and mode options (yadif, bwdif, w3fdif, estdif with send_frame/send_field modes) to the Advanced panel, with persistence in profiles +* Adding #520 "Source" option for the output file extension, matching the output container to the source format for batch encoding mixed MKV/MP4 files (thanks to x9sim9) +* Adding #523 drag and drop folder support in the Concatenation Builder (thanks to GokieKS) +* Adding #526 -movflags +faststart toggle for MP4/MOV containers, enabled by default (thanks to Aria) +* Adding #594 codec and codec+profile matching options for audio profile pattern matching, allowing users to distinguish between e.g. DTS-HD MA and regular DTS tracks (thanks to Damundai) +* Adding #665 profile-level control for subtitle "default" and "forced" disposition flags with Keep Source, Clear All, and Set on First Track modes (thanks to legoleigh) +* Adding #677 "Keep source loaded after adding to queue" setting to allow tweaking and re-queuing the same source without reloading (thanks to Mine181) +* Adding #682 HE-AAC and HE-AAC v2 audio profile selection for AAC and libfdk_aac codecs (thanks to Augusto7743) +* Adding Reverse Video option in the Advanced panel (applies FFmpeg reverse video filter and areverse audio filter for converted tracks) +* Adding rigaya encoder support for Equalizer (--vpp-tweak), Denoise (--vpp-nlmeans/knn/pmd), Deblock (--vpp-deblock), Output FPS (--vpp-fps), and Video Track Title — only Video Speed and Reverse Video remain unsupported +* Adding #544 Download HDR10 Metadata button in Source Details tab for exporting mastering display and content light level data in x265-compatible format (thanks to yundebian918) +* Adding structured table view to Source Details tab with grouped sections, human-readable value formatting, Download JSON export, and full path tooltips on source/output filename fields +* Adding warning in Data & Attachments tab and reason text when tracks are incompatible with the output format (e.g. data streams in MKV) +* Changing Advanced panel layout to use QGroupBox sections (Video Details, Frame Rate, Video Processing, Color & Appearance, Color, Output) with restoration filters separated from color correction filters +* Fixing #741 VideoToolbox HEVC and H264 encoders passing invalid numeric profile index instead of profile name, causing "incorrect parameters" errors on macOS (thanks to enaveso) +* Fixing #560 queue items could not be removed while encoding — now allows removing non-running items and grays out unavailable controls (move, reload, load queue) during encoding (thanks to 54x1) +* Fixing #736 thumbnail and crop preview generation failing with FFmpeg 8.0+ by adding `-update 1` flag required by the image2 muxer for single-image output (thanks to kliffgomel) +* Fixing #738 conversion error with Google Pixel videos by disabling data stream mapping for Matroska containers which do not support data streams (attachments like fonts are still allowed) (thanks to Hankuuuu) +* Fixing attachment streams (fonts, etc.) losing mimetype/filename metadata during conversion due to `-map_metadata -1` stripping per-stream tags required by the muxers +* Fixing cover image metadata targeting wrong output stream when data/attachment tracks are present (FFmpeg places `-map` streams before `-attach` streams in output order) +* Fixing data streams being auto-included by FFmpeg when encoding to MKV, causing "Only audio, video, and subtitles are supported" errors — now explicitly excludes unmapped data with `-dn` and rechecks compatibility when output format changes +* Fixing #739 unable to add more than 1 video per session due to AttributeError when closing audio track widgets (thanks to mrdav1) +* Fixing #130 auto-crop producing invalid crop rectangles for non-standard aspect ratios (4:3 FHD, 2.76:1 ultra-wide) due to independent dimension tracking across cropdetect frames and Python falsy-zero bug (thanks to hallmansm) +* Fixing HDR tonemapping failing with zscale "no path between colorspaces" by passing complete source colorspace parameters (primaries, matrix, transfer) to the zscale filter, with fallback retry for crop previews when tonemapping still fails +* Fixing bitrate combobox being unreadably small on x264 and x265 encoders — combo now sizes to fit its widest item +* Fixing rigaya encoder pad/letterbox calculation ignoring crop dimensions due to incorrect type check +* Fixing rigaya encoders (NVEncC, QSVEncC, VCEEncC) crashing with no output when data streams are copied to non-MKV containers — `--data-copy` and `--attachment-copy` are now only emitted for Matroska output +* Fixing startup crash when queue file exists but is missing the 'queue' key + ## Version 6.2.1 * Fixing #529 window geometry and menu anchoring issues when displays are powered off/on or reconfigured during use (thanks to wiznillyp) diff --git a/cmd/installer/install.go b/cmd/installer/install.go new file mode 100644 index 00000000..99b9fa05 --- /dev/null +++ b/cmd/installer/install.go @@ -0,0 +1,437 @@ +package main + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "github.com/klauspost/compress/zstd" + "golang.org/x/sys/windows/registry" +) + +const ( + productName = "FastFlix" + productAuthor = "Chris Griffith" +) + +// installMode determines where to install and which registry root to use. +type installMode int + +const ( + modeUser installMode = iota // Install for current user only + modeAllUsers // Install for all users (admin required) +) + +// installPaths returns the install directory, registry root, and Start Menu path for the given mode. +func installPaths(mode installMode) (installDir string, regRoot registry.Key, startMenu string) { + switch mode { + case modeUser: + installDir = filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", productName) + regRoot = registry.CURRENT_USER + startMenu = filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + case modeAllUsers: + installDir = filepath.Join(os.Getenv("ProgramFiles"), productName) + regRoot = registry.LOCAL_MACHINE + startMenu = filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + } + return +} + +// countArchiveEntries counts the number of file entries in the tar.zst archive. +func countArchiveEntries(data []byte) int { + decoder, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return 0 + } + defer decoder.Close() + + count := 0 + tr := tar.NewReader(decoder) + for { + _, err := tr.Next() + if err != nil { + break + } + count++ + } + return count +} + +// extractTarZstd decompresses a tar.zst archive to the target directory. +// Calls progressFn(current, total) after each file is extracted. +func extractTarZstd(data []byte, targetDir string, progressFn func(current, total int)) error { + total := countArchiveEntries(data) + if total == 0 { + total = 1 + } + + decoder, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("open zstd: %w", err) + } + defer decoder.Close() + + current := 0 + tr := tar.NewReader(decoder) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read tar: %w", err) + } + + target := filepath.Join(targetDir, header.Name) + + // Prevent path traversal + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(targetDir)+string(os.PathSeparator)) { + continue + } + + switch header.Typeflag { + case tar.TypeDir: + os.MkdirAll(target, 0755) + case tar.TypeReg: + os.MkdirAll(filepath.Dir(target), 0755) + outFile, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("create %s: %w", header.Name, err) + } + _, err = io.Copy(outFile, tr) + outFile.Close() + if err != nil { + return fmt.Errorf("extract %s: %w", header.Name, err) + } + } + + current++ + if progressFn != nil { + progressFn(current, total) + } + } + return nil +} + +// writeRegistry creates registry entries for the installation. +func writeRegistry(installDir string, regRoot registry.Key) { + // Application registry key + appKey, _, _ := registry.CreateKey(regRoot, `SOFTWARE\`+productName, registry.SET_VALUE) + if appKey != 0 { + appKey.SetStringValue("Install_Dir", installDir) + appKey.Close() + } + + // Determine uninstall flag based on mode + uninstallFlag := "--user" + if regRoot == registry.LOCAL_MACHINE { + uninstallFlag = "--allusers" + } + + // Uninstall registry key + uninstallKeyPath := `Software\Microsoft\Windows\CurrentVersion\Uninstall\` + productName + uKey, _, _ := registry.CreateKey(regRoot, uninstallKeyPath, registry.SET_VALUE) + if uKey != 0 { + uKey.SetStringValue("DisplayName", productName) + uKey.SetStringValue("DisplayVersion", Version) + uKey.SetStringValue("Publisher", productAuthor) + uKey.SetStringValue("DisplayIcon", filepath.Join(installDir, "FastFlix.exe")) + uKey.SetStringValue("UninstallString", `"`+filepath.Join(installDir, "uninstall.exe")+`" --uninstall `+uninstallFlag) + uKey.SetStringValue("InstallLocation", installDir) + uKey.SetDWordValue("NoModify", 1) + uKey.SetDWordValue("NoRepair", 1) + uKey.Close() + } +} + +// writeInstalledSize calculates and writes the EstimatedSize registry value. +func writeInstalledSize(installDir string, regRoot registry.Key) { + var totalKB int64 + filepath.Walk(installDir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + totalKB += info.Size() / 1024 + } + return nil + }) + + uKey, err := registry.OpenKey(regRoot, + `Software\Microsoft\Windows\CurrentVersion\Uninstall\`+productName, + registry.SET_VALUE) + if err == nil { + uKey.SetDWordValue("EstimatedSize", uint32(totalKB)) + uKey.Close() + } +} + +// createShortcuts creates Start Menu shortcuts. +func createShortcuts(installDir, startMenu string) { + os.MkdirAll(startMenu, 0755) + + createShortcutFile( + filepath.Join(startMenu, productName+".lnk"), + filepath.Join(installDir, "FastFlix.exe"), + installDir, + "FastFlix Video Encoder", + ) +} + +// createShortcutFile creates a .lnk shortcut via PowerShell. +func createShortcutFile(lnkPath, targetPath, workDir, description string) { + script := fmt.Sprintf( + `$ws = New-Object -ComObject WScript.Shell; `+ + `$sc = $ws.CreateShortcut('%s'); `+ + `$sc.TargetPath = '%s'; `+ + `$sc.WorkingDirectory = '%s'; `+ + `$sc.Description = '%s'; `+ + `$sc.Save()`, + lnkPath, targetPath, workDir, description, + ) + cmd := exec.Command("powershell", "-NoProfile", "-Command", script) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + cmd.Run() +} + +// copyFile copies a file from src to dst. +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0755) +} + +// isAdmin checks if the current process has admin privileges. +func isAdmin() bool { + _, err := os.Open("\\\\.\\PHYSICALDRIVE0") + return err == nil +} + +// relaunchAsAdmin re-launches with admin privileges and the given extra args. +func relaunchAsAdmin(extraArgs ...string) { + verb, _ := syscall.UTF16PtrFromString("runas") + exe, _ := syscall.UTF16PtrFromString(os.Args[0]) + + args := strings.Join(extraArgs, " ") + argPtr, _ := syscall.UTF16PtrFromString(args) + cwd, _ := syscall.UTF16PtrFromString(".") + + shell32 := syscall.NewLazyDLL("shell32.dll") + shellExecute := shell32.NewProc("ShellExecuteW") + shellExecute.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(exe)), + uintptr(unsafe.Pointer(argPtr)), uintptr(unsafe.Pointer(cwd)), 1) +} + +// existingInstall represents a single discovered FastFlix installation. +type existingInstall struct { + RegRoot registry.Key + InstallDir string + UninstallStr string + Version string +} + +// cleanupInstallation performs a thorough removal of a FastFlix installation. +// Handles both old NSIS installs and new Go installer installs, removing: +// - The install directory itself (elevates via UAC if in Program Files) +// - Start Menu shortcuts (all known locations) +// - Registry keys (both HKLM and HKCU) +// - Desktop shortcuts if any +// Returns true if elevation was needed and launched (caller should wait for re-run). +func cleanupInstallation(inst existingInstall) bool { + elevated := false + + // 1. Remove the install directory + if inst.InstallDir != "" { + err := os.RemoveAll(inst.InstallDir) + if err != nil && isProtectedPath(inst.InstallDir) && !isAdmin() { + // Need admin to delete from Program Files — elevate + elevated = true + elevatedCleanup(inst.InstallDir) + } + } + + // 2. Remove Start Menu shortcuts from ALL possible locations + startMenuPaths := []string{ + // Common (all-users) Start Menu — old NSIS installer uses this + filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", productName), + // User Start Menu — new Go installer user-mode uses this + filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", productName), + } + for _, sm := range startMenuPaths { + if _, err := os.Stat(sm); err == nil { + err2 := os.RemoveAll(sm) + if err2 != nil && !isAdmin() { + elevatedDelete(sm) + } + } + } + + // 3. Remove Desktop shortcuts if present + desktopPaths := []string{ + filepath.Join(os.Getenv("USERPROFILE"), "Desktop", productName+".lnk"), + filepath.Join(os.Getenv("PUBLIC"), "Desktop", productName+".lnk"), + } + for _, d := range desktopPaths { + os.Remove(d) + } + + // 4. Remove ALL registry keys (both roots, all paths including WOW6432Node) + cleanAllRegistryKeys() + + return elevated +} + +// isProtectedPath checks if a path is under Program Files or another admin-protected location. +func isProtectedPath(dir string) bool { + dirLower := strings.ToLower(filepath.Clean(dir)) + protectedRoots := []string{ + strings.ToLower(os.Getenv("ProgramFiles")), + strings.ToLower(os.Getenv("ProgramW6432")), + } + programFilesX86 := os.Getenv("ProgramFiles(x86)") + if programFilesX86 != "" { + protectedRoots = append(protectedRoots, strings.ToLower(programFilesX86)) + } + for _, root := range protectedRoots { + if root != "" && strings.HasPrefix(dirLower, root) { + return true + } + } + return false +} + +// elevatedCleanup runs an elevated cmd.exe to remove a directory that requires admin access. +// Triggers a UAC prompt. +func elevatedCleanup(dir string) { + // Use cmd /c rmdir /s /q via ShellExecute with "runas" verb + verb, _ := syscall.UTF16PtrFromString("runas") + exe, _ := syscall.UTF16PtrFromString("cmd.exe") + args, _ := syscall.UTF16PtrFromString(`/c rmdir /s /q "` + dir + `"`) + cwd, _ := syscall.UTF16PtrFromString(".") + + shell32 := syscall.NewLazyDLL("shell32.dll") + shellExecute := shell32.NewProc("ShellExecuteW") + shellExecute.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(exe)), + uintptr(unsafe.Pointer(args)), uintptr(unsafe.Pointer(cwd)), 0) // SW_HIDE +} + +// elevatedDelete runs an elevated cmd.exe to remove a file/directory that requires admin access. +func elevatedDelete(path string) { + info, err := os.Stat(path) + if err != nil { + return + } + verb, _ := syscall.UTF16PtrFromString("runas") + exe, _ := syscall.UTF16PtrFromString("cmd.exe") + var cmdArgs string + if info.IsDir() { + cmdArgs = `/c rmdir /s /q "` + path + `"` + } else { + cmdArgs = `/c del /f /q "` + path + `"` + } + args, _ := syscall.UTF16PtrFromString(cmdArgs) + cwd, _ := syscall.UTF16PtrFromString(".") + + shell32 := syscall.NewLazyDLL("shell32.dll") + shellExecute := shell32.NewProc("ShellExecuteW") + shellExecute.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(exe)), + uintptr(unsafe.Pointer(args)), uintptr(unsafe.Pointer(cwd)), 0) // SW_HIDE +} + +// cleanAllRegistryKeys removes all FastFlix registry keys from all known locations, +// including the WOW6432Node path that 32-bit NSIS installers write to. +func cleanAllRegistryKeys() { + regPaths := []string{ + `Software\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + `SOFTWARE\` + productName, + } + for _, root := range []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} { + for _, p := range regPaths { + registry.DeleteKey(root, p) + } + } +} + +// findAllExistingInstalls checks both registry and known disk paths for existing FastFlix installations. +// Returns all found installations (could be multiple: old NSIS in Program Files + user install). +func findAllExistingInstalls() []existingInstall { + var results []existingInstall + seen := map[string]bool{} // deduplicate by install dir (lowercased) + + // 1. Check registry (both HKLM and HKCU, including WOW6432Node for 32-bit NSIS installs) + uninstallPaths := []string{ + `Software\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + } + for _, root := range []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} { + for _, regPath := range uninstallPaths { + uKey, err := registry.OpenKey(root, regPath, registry.QUERY_VALUE) + if err != nil { + continue + } + dir, _, _ := uKey.GetStringValue("InstallLocation") + if dir == "" { + // Try the old NSIS-style key + appKey, err2 := registry.OpenKey(root, `SOFTWARE\`+productName, registry.QUERY_VALUE) + if err2 == nil { + dir, _, _ = appKey.GetStringValue("Install_Dir") + appKey.Close() + } + } + uninstall, _, _ := uKey.GetStringValue("UninstallString") + version, _, _ := uKey.GetStringValue("DisplayVersion") + uKey.Close() + + if dir != "" || uninstall != "" { + dirKey := strings.ToLower(filepath.Clean(dir)) + if !seen[dirKey] { + seen[dirKey] = true + results = append(results, existingInstall{ + RegRoot: root, + InstallDir: dir, + UninstallStr: uninstall, + Version: version, + }) + } + } + } + } + + // 2. Check known disk paths even if registry keys are missing + // (handles installs where registry was cleaned up or never written) + knownPaths := []string{ + filepath.Join(os.Getenv("ProgramFiles"), productName), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", productName), + } + for _, dir := range knownPaths { + dirKey := strings.ToLower(filepath.Clean(dir)) + if seen[dirKey] { + continue + } + // Check if FastFlix.exe exists at this path + exePath := filepath.Join(dir, "FastFlix.exe") + if _, err := os.Stat(exePath); err != nil { + continue + } + seen[dirKey] = true + // Check for uninstaller + uninstallStr := "" + uninstallExe := filepath.Join(dir, "uninstall.exe") + if _, err := os.Stat(uninstallExe); err == nil { + uninstallStr = `"` + uninstallExe + `" --uninstall` + } + results = append(results, existingInstall{ + InstallDir: dir, + UninstallStr: uninstallStr, + }) + } + + return results +} diff --git a/cmd/installer/main.go b/cmd/installer/main.go new file mode 100644 index 00000000..429f7ef3 --- /dev/null +++ b/cmd/installer/main.go @@ -0,0 +1,127 @@ +// FastFlix Installer +// +// Polished GUI installer using lxn/walk with dark theme. +// Supports per-user and all-users installation modes. +// +// Build: +// go build -ldflags="-s -w -H windowsgui -X main.Version=6.3.0" -o FastFlix_installer.exe ./cmd/installer + +package main + +import ( + "os" + "os/exec" + "runtime" + "strings" + "syscall" + "unsafe" +) + +// Version is set at build time via -ldflags="-X main.Version=6.3.0" +var Version = "dev" + +func main() { + runtime.LockOSThread() + + // Single-instance check via named mutex (skip for elevated helpers and uninstall mode) + args := os.Args[1:] + isHelper := containsArg(args, "--elevated-install") || containsArg(args, "--elevated-copy") || containsArg(args, "--uninstall") + if !isHelper { + mutexName, _ := syscall.UTF16PtrFromString("Global\\FastFlixInstaller") + handle, _, err := kernel32DLL.NewProc("CreateMutexW").Call(0, 1, uintptr(unsafe.Pointer(mutexName))) + if handle != 0 && err == syscall.ERROR_ALREADY_EXISTS { + messageBox("FastFlix Installer", "The FastFlix installer is already running.", 0x40) + return + } + // mutex handle stays open until process exits — no need to close it + } + + // Handle command-line flags + + // --elevated-install: silent elevated helper (extracts + registry + shortcuts) + // Called by the GUI installer when "Install for all users" is clicked + if containsArg(args, "--elevated-install") { + runElevatedInstall(args) + return + } + + // Legacy --elevated-copy for backwards compat + if containsArg(args, "--elevated-copy") { + runElevatedCopy(args) + return + } + + // Default: show the installer GUI + runInstallerGUI() +} + +// runElevatedCopy is a silent elevated helper that copies an already-extracted +// distribution from a temp directory to Program Files and writes HKLM registry. +// Called with: --elevated-copy --source --dest +func runElevatedCopy(args []string) { + src := getArgValue(args, "--source") + dst := getArgValue(args, "--dest") + if src == "" || dst == "" { + os.Exit(1) + } + + // Copy files from temp to Program Files + os.MkdirAll(dst, 0755) + // Use robocopy for reliable admin copy + cmd := exec.Command("robocopy", src, dst, "/E", "/NFL", "/NDL", "/NJH", "/NJS", "/nc", "/ns", "/np") + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + cmd.Run() + // robocopy returns non-zero on success (1=files copied), only 8+ is error + + // Write HKLM registry + _, regRoot, startMenu := installPaths(modeAllUsers) + writeRegistry(dst, regRoot) + writeInstalledSize(dst, regRoot) + createShortcuts(dst, startMenu) + + // Write success marker + os.WriteFile(src+`\.done`, []byte("ok"), 0644) +} + +// runElevatedInstall does everything: extract embedded archive to dest, registry, shortcuts. +// Called as an elevated process — no UI, writes a marker file when done. +func runElevatedInstall(args []string) { + dst := getArgValue(args, "--dest") + marker := getArgValue(args, "--marker") + if dst == "" { + os.Exit(1) + } + + // Extract embedded archive directly to destination (includes uninstall.exe) + os.MkdirAll(dst, 0755) + extractTarZstd(distArchive, dst, nil) + + // Write HKLM registry + shortcuts + _, regRoot, startMenu := installPaths(modeAllUsers) + writeRegistry(dst, regRoot) + writeInstalledSize(dst, regRoot) + createShortcuts(dst, startMenu) + + // Write completion marker + if marker != "" { + os.WriteFile(marker, []byte("ok"), 0644) + } +} + +func getArgValue(args []string, key string) string { + for i, a := range args { + if strings.EqualFold(a, key) && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func containsArg(args []string, flag string) bool { + for _, a := range args { + if strings.EqualFold(a, flag) { + return true + } + } + return false +} diff --git a/cmd/installer/process.go b/cmd/installer/process.go new file mode 100644 index 00000000..48bc6558 --- /dev/null +++ b/cmd/installer/process.go @@ -0,0 +1,38 @@ +package main + +import ( + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// isProcessRunning checks if a process with the given name is currently running. +// Uses the Windows Toolhelp32 API for efficient process enumeration. +func isProcessRunning(name string) bool { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return false + } + defer windows.CloseHandle(snapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + err = windows.Process32First(snapshot, &entry) + if err != nil { + return false + } + + for { + exeName := windows.UTF16ToString(entry.ExeFile[:]) + if strings.EqualFold(exeName, name) { + return true + } + err = windows.Process32Next(snapshot, &entry) + if err != nil { + break + } + } + return false +} diff --git a/cmd/installer/resources.go b/cmd/installer/resources.go new file mode 100644 index 00000000..fe706f82 --- /dev/null +++ b/cmd/installer/resources.go @@ -0,0 +1,116 @@ +package main + +import ( + _ "embed" + "encoding/json" + "strings" + "syscall" +) + +//go:embed fastflix_dist.tar.zst +var distArchive []byte + +//go:embed terms_translations.json +var termsTranslationsJSON string + +//go:embed licenses.txt +var licensesFileText string + +const mitLicense = `The MIT License + +Copyright (c) 2019-2025 Chris Griffith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.` + +var termsAndLicensesText string + +func init() { + terms := loadTermsForLocale() + + // Use \r\n throughout for Windows EDIT control compatibility + sep := "\r\n\r\n" + strings.Repeat("\u2550", 50) + "\r\n" + + termsAndLicensesText = terms + + sep + "SOFTWARE LICENSE (MIT)\r\n" + strings.Repeat("\u2550", 50) + "\r\n\r\n" + + strings.ReplaceAll(mitLicense, "\n", "\r\n") + + sep + "THIRD-PARTY LICENSES\r\n" + strings.Repeat("\u2550", 50) + "\r\n\r\n" + + strings.ReplaceAll(licensesFileText, "\n", "\r\n") +} + +// loadTermsForLocale returns the terms text for the current system language. +// If the system language is not English and is supported, shows the translation +// followed by a divider and the English version. +func loadTermsForLocale() string { + var translations map[string]string + json.Unmarshal([]byte(termsTranslationsJSON), &translations) + + english := translations["eng"] + if english == "" { + english = "FastFlix Terms and Agreements\r\n\r\n(Terms text not available)" + } + + langCode := detectSystemLanguage() + + if langCode != "eng" { + if translated, ok := translations[langCode]; ok && translated != "" { + divider := "\r\n\r\n" + strings.Repeat("\u2500", 50) + "\r\n\r\n" + return translated + divider + english + } + } + return english +} + +// detectSystemLanguage uses GetUserDefaultUILanguage to detect the OS language +// and maps it to a FastFlix language code. +func detectSystemLanguage() string { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + ret, _, _ := kernel32.NewProc("GetUserDefaultUILanguage").Call() + primaryLang := int(ret) & 0x3FF + + switch primaryLang { + case 0x07: + return "deu" // German + case 0x0C: + return "fra" // French + case 0x10: + return "ita" // Italian + case 0x0A: + return "spa" // Spanish + case 0x04: + return "chs" // Chinese Simplified + case 0x11: + return "jpn" // Japanese + case 0x19: + return "rus" // Russian + case 0x16: + return "por" // Portuguese + case 0x1D: + return "swe" // Swedish + case 0x15: + return "pol" // Polish + case 0x22: + return "ukr" // Ukrainian + case 0x12: + return "kor" // Korean + case 0x18: + return "ron" // Romanian + default: + return "eng" + } +} diff --git a/cmd/installer/ui.go b/cmd/installer/ui.go new file mode 100644 index 00000000..d958207a --- /dev/null +++ b/cmd/installer/ui.go @@ -0,0 +1,1389 @@ +package main + +import ( + "fmt" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +// Win32 API bindings +var ( + user32DLL = syscall.NewLazyDLL("user32.dll") + kernel32DLL = syscall.NewLazyDLL("kernel32.dll") + gdi32DLL = syscall.NewLazyDLL("gdi32.dll") + dwmapiDLL = syscall.NewLazyDLL("dwmapi.dll") + comctl32DLL = syscall.NewLazyDLL("comctl32.dll") + + registerClassExW = user32DLL.NewProc("RegisterClassExW") + createWindowExW = user32DLL.NewProc("CreateWindowExW") + showWindow = user32DLL.NewProc("ShowWindow") + updateWindow = user32DLL.NewProc("UpdateWindow") + getMessageW = user32DLL.NewProc("GetMessageW") + translateMessage = user32DLL.NewProc("TranslateMessage") + dispatchMessageW = user32DLL.NewProc("DispatchMessageW") + defWindowProcW = user32DLL.NewProc("DefWindowProcW") + postQuitMessage = user32DLL.NewProc("PostQuitMessage") + sendMessageW = user32DLL.NewProc("SendMessageW") + enableWindow = user32DLL.NewProc("EnableWindow") + setTimer = user32DLL.NewProc("SetTimer") + destroyWindow = user32DLL.NewProc("DestroyWindow") + getSystemMetrics = user32DLL.NewProc("GetSystemMetrics") + invalidateRect = user32DLL.NewProc("InvalidateRect") + loadImageW = user32DLL.NewProc("LoadImageW") + loadIconW = user32DLL.NewProc("LoadIconW") + drawIconEx = user32DLL.NewProc("DrawIconEx") + beginPaint = user32DLL.NewProc("BeginPaint") + endPaint = user32DLL.NewProc("EndPaint") + fillRectProc = user32DLL.NewProc("FillRect") + setWindowTextW = user32DLL.NewProc("SetWindowTextW") + getClientRectProc = user32DLL.NewProc("GetClientRect") + getWindowTextW = user32DLL.NewProc("GetWindowTextW") + isWindowProc = user32DLL.NewProc("IsWindow") + setForegroundWindow = user32DLL.NewProc("SetForegroundWindow") + + createSolidBrush = gdi32DLL.NewProc("CreateSolidBrush") + createFontW = gdi32DLL.NewProc("CreateFontW") + selectObject = gdi32DLL.NewProc("SelectObject") + setTextColor = gdi32DLL.NewProc("SetTextColor") + setBkMode = gdi32DLL.NewProc("SetBkMode") + drawTextW = user32DLL.NewProc("DrawTextW") + deleteObject = gdi32DLL.NewProc("DeleteObject") + + initCommonControlsEx = comctl32DLL.NewProc("InitCommonControlsEx") + dwmSetWindowAttribute = dwmapiDLL.NewProc("DwmSetWindowAttribute") + getModuleHandleW = kernel32DLL.NewProc("GetModuleHandleW") +) + +// Win32 constants +const ( + wsOverlapped = 0x00000000 + wsCaption = 0x00C00000 + wsSysMenu = 0x00080000 + wsMinimizeBox = 0x00020000 + wsVisible = 0x10000000 + wsChild = 0x40000000 + wsTabStop = 0x00010000 + wsExComposited = 0x02000000 + wsVScroll = 0x00200000 + wsBorder = 0x00800000 + + bsAutoCheckBox = 0x00000003 + bsOwnerDraw = 0x0000000B + ssCenter = 0x00000001 + ssNotify = 0x00000100 + ssOwnerDraw = 0x0000000D + pbsSmooth = 0x01 + esMultiline = 0x0004 + esReadOnly = 0x0800 + esAutoVScroll = 0x0040 + + wmDestroy = 0x0002 + wmPaint = 0x000F + wmClose = 0x0010 + wmSetText = 0x000C + wmSetFont = 0x0030 + wmSetIcon = 0x0080 + wmCommand = 0x0111 + wmTimer = 0x0113 + wmCtlColorStatic = 0x0138 + wmCtlColorBtn = 0x0135 + wmCtlColorEdit = 0x0133 + wmDrawItem = 0x002B + + bmGetCheck = 0x00F0 + bstChecked = 0x0001 + pbmSetRange = 0x0406 + pbmSetPos = 0x0402 + emSetMargins = 0x00D3 + + bnClicked = 0 + smCXScreen = 0 + smCYScreen = 1 + swShow = 5 + swHide = 0 + transparent = 1 + imageIcon = 1 + + dtCenter = 0x00000001 + dtSingleLine = 0x00000020 + dtVCenter = 0x00000004 +) + +// Control IDs +const ( + idcAgree = 101 + idcTerms = 102 + idcUninstall = 103 + idcUser = 104 + idcAll = 105 + idcWarning = 106 + idcProgress = 107 + idcProgLabel = 108 + idcLaunch = 109 + idcInstInfo = 110 + idcClose = 111 + idtProcCheck = 1 + idcDlgEdit = 201 + idcDlgClose = 202 +) + +// Colors (COLORREF = 0x00BBGGRR) +const ( + clrBg = 0x00333333 + clrText = 0x00E6E6E6 + clrSubtext = 0x00A0A0A0 + clrAccent = 0x00F0AA46 // #46AAF0 in BGR (cyan) + clrRed = 0x003C3CDC + clrBtnBg = 0x00F0D030 // bright cyan #30D0F0 in BGR + clrBtnText = 0x00202020 // dark text on bright buttons + clrBtnDis = 0x00555555 + clrBtnRed = 0x004848CC + clrBtnRedDis = 0x00444466 + clrEditBg = 0x003A3A3A +) + +// Window dimensions — computed at runtime from screen size +var ( + winWidth int + winHeight int + iconSz int +) + +type wndState struct { + hwnd syscall.Handle + hInstance syscall.Handle + hBgBrush syscall.Handle + hEditBrush syscall.Handle + + hTitleFont syscall.Handle + hNormalFont syscall.Handle + hSmallFont syscall.Handle + hIcon syscall.Handle + hShieldIcon syscall.Handle + + hAgree syscall.Handle + hTerms syscall.Handle + hUninstallBtn syscall.Handle + hUserBtn syscall.Handle + hAllBtn syscall.Handle + hWarning syscall.Handle + hProgress syscall.Handle + hProgLabel syscall.Handle + hLaunchBtn syscall.Handle + hCloseBtn syscall.Handle + hInstallInfo syscall.Handle // label showing where previous versions were found + + processRunning bool + installing bool + agreeChecked bool // owner-drawn checkbox state + termsLinkX int32 // X position where "Terms and Conditions" text starts (for hit-test) + installedDir string // set after install completes, for launch button + progressValue int // 0-100 progress percentage + uninstallNeedsAdmin bool // true if any existing install is in Program Files + existingInstalls []existingInstall // all detected installs (for info display) + existingFound bool + existingVersion string + existingDir string + previousRemoved bool +} + +var state wndState + +// Win32 struct types +type wndClassExW struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance syscall.Handle + Icon syscall.Handle + Cursor syscall.Handle + Background syscall.Handle + MenuName *uint16 + ClassName *uint16 + IconSm syscall.Handle +} + +type paintStruct struct { + HDC syscall.Handle + Erase int32 + RcPaint rect + Restore int32 + IncUpdate int32 + Reserved [32]byte +} + +type rect struct{ Left, Top, Right, Bottom int32 } + +type msg struct { + Hwnd syscall.Handle + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt struct{ X, Y int32 } +} + +type drawItemStruct struct { + CtlType uint32 + CtlID uint32 + ItemID uint32 + ItemAction uint32 + ItemState uint32 + HwndItem syscall.Handle + HDC syscall.Handle + RcItem rect + ItemData uintptr +} + +type initCommonControlsExStruct struct { + Size uint32 + ICC uint32 +} + +func utf16Ptr(s string) *uint16 { + p, _ := syscall.UTF16PtrFromString(s) + return p +} + +func runInstallerGUI() { + runtime.LockOSThread() + + icc := initCommonControlsExStruct{Size: 8, ICC: 0x00000020} + initCommonControlsEx.Call(uintptr(unsafe.Pointer(&icc))) + + hInst, _, _ := getModuleHandleW.Call(0) + state.hInstance = syscall.Handle(hInst) + + // Compute window size relative to screen (width ~20% of screen) + screenW, _, _ := getSystemMetrics.Call(smCXScreen) + screenH, _, _ := getSystemMetrics.Call(smCYScreen) + winWidth = int(screenW) * 20 / 100 + if winWidth < 400 { + winWidth = 400 + } + winHeight = winWidth * 150 / 100 // 1.5:1 aspect ratio (tall) + if winHeight > int(screenH)*85/100 { + winHeight = int(screenH) * 85 / 100 + } + iconSz = winWidth * 30 / 100 // Icon is ~30% of window width + if iconSz > 256 { + iconSz = 256 + } + + // Brushes + ret, _, _ := createSolidBrush.Call(clrBg) + state.hBgBrush = syscall.Handle(ret) + ret, _, _ = createSolidBrush.Call(clrEditBg) + state.hEditBrush = syscall.Handle(ret) + + // Fonts — scale with window width + titlePt := winWidth * 54 / 660 + normalPt := winWidth * 24 / 660 + smallPt := winWidth * 20 / 660 + state.hTitleFont = makeFont("Segoe UI", titlePt, true) + state.hNormalFont = makeFont("Segoe UI", normalPt, false) + state.hSmallFont = makeFont("Segoe UI", smallPt, false) + + // App icon + ret, _, _ = loadImageW.Call(hInst, uintptr(1), imageIcon, uintptr(iconSz), uintptr(iconSz), 0) + state.hIcon = syscall.Handle(ret) + + // UAC shield icon — try multiple methods + ret, _, _ = loadImageW.Call(0, uintptr(106), imageIcon, 0, 0, 0x00008000) // OIC_SHIELD=106, LR_SHARED + if ret == 0 { + ret, _, _ = loadIconW.Call(0, uintptr(32518)) // IDI_SHIELD fallback + } + state.hShieldIcon = syscall.Handle(ret) + + // Register window class + className := utf16Ptr("FastFlixInstaller") + cursor, _, _ := user32DLL.NewProc("LoadCursorW").Call(0, uintptr(32512)) + + wc := wndClassExW{ + Size: uint32(unsafe.Sizeof(wndClassExW{})), + Style: 0x0003, + WndProc: syscall.NewCallback(wndProc), + Instance: state.hInstance, + Icon: state.hIcon, + Cursor: syscall.Handle(cursor), + Background: state.hBgBrush, + ClassName: className, + IconSm: state.hIcon, + } + registerClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + posX := (int(screenW) - winWidth) / 2 + posY := (int(screenH) - winHeight) / 2 + + hwnd, _, _ := createWindowExW.Call( + wsExComposited, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(utf16Ptr("FastFlix Installer"))), + 0x00800000, // WS_BORDER only — no title bar, no system menu + uintptr(posX), uintptr(posY), uintptr(winWidth), uintptr(winHeight), + 0, 0, hInst, 0, + ) + state.hwnd = syscall.Handle(hwnd) + + // Dark title bar + var dark int32 = 1 + dwmSetWindowAttribute.Call(hwnd, 20, uintptr(unsafe.Pointer(&dark)), 4) + + // Window icon + if state.hIcon != 0 { + sendMessageW.Call(hwnd, wmSetIcon, 0, uintptr(state.hIcon)) + sendMessageW.Call(hwnd, wmSetIcon, 1, uintptr(state.hIcon)) + } + + // Detect existing installs and process state BEFORE creating controls + detectExistingInstall() + state.processRunning = isProcessRunning("FastFlix.exe") + + createControls(hwnd, hInst) + + // Check process every 10 seconds + setTimer.Call(hwnd, idtProcCheck, 10000, 0) + + showWindow.Call(hwnd, swShow) + updateWindow.Call(hwnd) + + // Message loop + var m msg + for { + r, _, _ := getMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if r == 0 { + break + } + translateMessage.Call(uintptr(unsafe.Pointer(&m))) + dispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } +} + +const bsAutoRadioButton = 0x00000009 + +func createControls(hwnd, hInst uintptr) { + cx := int32(winWidth) + pad := cx * 9 / 100 // ~9% padding on each side + btnW := cx - pad*2 + btnH := int32(winHeight) * 7 / 100 // button height ~7% of window + + // --- Checkbox + Terms link as a single owner-drawn button --- + // This eliminates the gap between "the" and "Terms" since we draw it all ourselves + y := int32(winHeight) * 50 / 100 + agreeW := cx * 65 / 100 + agreeX := (cx - agreeW) / 2 + ctlH := int32(winHeight) * 4 / 100 + + // Hide agree checkbox if we need to uninstall first + agreeStyle := uint32(wsChild | wsTabStop | bsOwnerDraw) + if !state.existingFound { + agreeStyle |= wsVisible + } + state.hAgree = createCtl("BUTTON", "", + agreeStyle, + agreeX, y, agreeW, ctlH, idcAgree, hwnd, hInst) + + // Terms link is hidden — clicks on the "Terms and Conditions" part of the + // owner-drawn button are detected in WM_LBUTTONUP by hit-testing + state.hTerms = 0 // no separate control + + y += int32(winHeight) * 8 / 100 + + // Uninstall button + uninstallText := "Uninstall previous version" + if len(state.existingInstalls) > 1 { + uninstallText = "Uninstall previous versions" + } + uninstallStyle := uint32(wsChild | wsTabStop | bsOwnerDraw) + if state.existingFound { + uninstallStyle |= wsVisible + } + state.hUninstallBtn = createCtl("BUTTON", uninstallText, uninstallStyle, + pad, y, btnW, btnH, idcUninstall, hwnd, hInst) + if state.processRunning { + enableWindow.Call(uintptr(state.hUninstallBtn), 0) + } + + // Remember where the uninstall button is for placing install buttons at the same Y + uninstallY := y + + y += btnH + ctlH/4 + + // Info label showing where previous installs were found (only if existing) + infoStyle := uint32(wsChild | ssCenter) + if state.existingFound { + infoStyle |= wsVisible + } + infoH := ctlH * int32(len(state.existingInstalls)) + if infoH < ctlH { + infoH = ctlH + } + state.hInstallInfo = createCtl("STATIC", buildInstallInfoText(), + infoStyle, pad, y, btnW, infoH, idcInstInfo, hwnd, hInst) + setFont(state.hInstallInfo, state.hSmallFont) + + // Install buttons — placed at same Y as uninstall button (they swap visibility) + installY := uninstallY + installStyle := uint32(wsChild | wsTabStop | bsOwnerDraw) + if !state.existingFound { + installStyle |= wsVisible + } + + state.hUserBtn = createCtl("BUTTON", "Install for just me", installStyle, + pad, installY, btnW, btnH, idcUser, hwnd, hInst) + enableWindow.Call(uintptr(state.hUserBtn), 0) + + installY += btnH + btnH*20/100 + + state.hAllBtn = createCtl("BUTTON", "Install for all users", installStyle, + pad, installY, btnW, btnH, idcAll, hwnd, hInst) + enableWindow.Call(uintptr(state.hAllBtn), 0) + + y = installY + btnH + btnH*40/100 + + // Warning label (below buttons area) + warningStyle := uint32(wsChild | ssCenter) + if state.processRunning { + warningStyle |= wsVisible + } + warnH := int32(winHeight) * 5 / 100 + state.hWarning = createCtl("STATIC", + "\u26D4 FastFlix is currently running, please close it to continue", + warningStyle, pad/2, y, cx-pad, warnH, idcWarning, hwnd, hInst) + setFont(state.hWarning, state.hNormalFont) + + // Progress bar + label + launch button — placed where the buttons normally are + // so they appear in a natural position when buttons are hidden during install + progY := int32(winHeight) * 58 / 100 + progH := int32(winHeight) * 3 / 100 + labelH := int32(winHeight) * 4 / 100 + + // Custom progress bar (owner-drawn static for dark bg + rounded corners) + state.hProgress = createCtl("STATIC", "", + wsChild|ssOwnerDraw, pad, progY, btnW, progH, idcProgress, hwnd, hInst) + + state.hProgLabel = createCtl("STATIC", "", + wsChild|ssCenter, pad, progY+progH+labelH/3, btnW, labelH, idcProgLabel, hwnd, hInst) + setFont(state.hProgLabel, state.hNormalFont) + + // Launch button — below the progress label (hidden until install completes) + state.hLaunchBtn = createCtl("BUTTON", "Launch FastFlix", + wsChild|wsTabStop|bsOwnerDraw, + pad, progY+progH+labelH+labelH, btnW, btnH, idcLaunch, hwnd, hInst) + + // Close button — small, centered at bottom, visible during uninstall/install screens + closeBtnW := cx * 20 / 100 + closeBtnH := int32(winHeight) * 4 / 100 + closeY := int32(winHeight) - closeBtnH - int32(winHeight)*8/100 + state.hCloseBtn = createCtl("BUTTON", "Close", + wsChild|wsVisible|wsTabStop|bsOwnerDraw, + (cx-closeBtnW)/2, closeY, closeBtnW, closeBtnH, idcClose, hwnd, hInst) +} + +func createCtl(class, text string, style uint32, x, y, w, h int32, id int, parent, hInst uintptr) syscall.Handle { + h2, _, _ := createWindowExW.Call(0, + uintptr(unsafe.Pointer(utf16Ptr(class))), + uintptr(unsafe.Pointer(utf16Ptr(text))), + uintptr(style), + uintptr(x), uintptr(y), uintptr(w), uintptr(h), + parent, uintptr(id), hInst, 0) + return syscall.Handle(h2) +} + +func setFont(hwnd, hFont syscall.Handle) { + sendMessageW.Call(uintptr(hwnd), wmSetFont, uintptr(hFont), 1) +} + +func makeFont(family string, size int, bold bool) syscall.Handle { + weight := int32(400) + if bold { + weight = 700 + } + h, _, _ := createFontW.Call(uintptr(uint32(-size)), 0, 0, 0, + uintptr(weight), 0, 0, 0, 1, 0, 0, 5, 0, + uintptr(unsafe.Pointer(utf16Ptr(family)))) + return syscall.Handle(h) +} + +func setText(hwnd syscall.Handle, text string) { + setWindowTextW.Call(uintptr(hwnd), uintptr(unsafe.Pointer(utf16Ptr(text)))) +} + +func sleepMs(ms uint32) { + kernel32DLL.NewProc("Sleep").Call(uintptr(ms)) +} + +// --- Window procedure --- + +func wndProc(hwnd syscall.Handle, m uint32, wParam, lParam uintptr) uintptr { + switch m { + case wmPaint: + paintWindow(hwnd) + return 0 + case wmCtlColorStatic: + hdc := syscall.Handle(wParam) + ctl := syscall.Handle(lParam) + setTextColor.Call(uintptr(hdc), clrText) + setBkMode.Call(uintptr(hdc), transparent) + if ctl == state.hWarning { + setTextColor.Call(uintptr(hdc), clrRed) + } + return uintptr(state.hBgBrush) + case wmCtlColorBtn: + return uintptr(state.hBgBrush) + case wmDrawItem: + dis := (*drawItemStruct)(unsafe.Pointer(lParam)) + if dis.HwndItem == state.hProgress { + drawProgressBar(dis) + } else { + drawButton(dis) + } + return 1 + case wmCommand: + handleCommand(int(wParam&0xFFFF), int((wParam>>16)&0xFFFF)) + return 0 + case wmTimer: + if int(wParam) == idtProcCheck { + checkProcessTimer() + } + return 0 + case wmClose: + destroyWindow.Call(uintptr(hwnd)) + return 0 + case wmDestroy: + postQuitMessage.Call(0) + return 0 + } + ret, _, _ := defWindowProcW.Call(uintptr(hwnd), uintptr(m), wParam, lParam) + return ret +} + +func paintWindow(hwnd syscall.Handle) { + var ps paintStruct + hdc, _, _ := beginPaint.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&ps))) + + var rc rect + getClientRectProc.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&rc))) + fillRectProc.Call(hdc, uintptr(unsafe.Pointer(&rc)), uintptr(state.hBgBrush)) + + // Icon centered at top (~16% of window height margin) + iconTop := int32(winHeight) * 16 / 100 + if state.hIcon != 0 { + iconX := (int32(winWidth) - int32(iconSz)) / 2 + drawIconEx.Call(hdc, uintptr(iconX), uintptr(iconTop), uintptr(state.hIcon), + uintptr(iconSz), uintptr(iconSz), 0, 0, 3) + } + + setBkMode.Call(hdc, transparent) + + // "FastFlix" title — gap below icon (~4% of window height) + titleGap := int32(winHeight) * 4 / 100 + titleY := iconTop + int32(iconSz) + titleGap + setTextColor.Call(hdc, clrText) + selectObject.Call(hdc, uintptr(state.hTitleFont)) + titleH := int32(winHeight) * 7 / 100 + titleR := rect{0, titleY, int32(winWidth), titleY + titleH} + drawTextW.Call(hdc, uintptr(unsafe.Pointer(utf16Ptr("FastFlix"))), + uintptr(len("FastFlix")), uintptr(unsafe.Pointer(&titleR)), dtCenter|dtSingleLine) + + // Version — tight below title + setTextColor.Call(hdc, clrSubtext) + selectObject.Call(hdc, uintptr(state.hNormalFont)) + ver := "version " + Version + verY := titleY + titleH - int32(winHeight)*1/100 + verH := int32(winHeight) * 4 / 100 + verR := rect{0, verY, int32(winWidth), verY + verH} + drawTextW.Call(hdc, uintptr(unsafe.Pointer(utf16Ptr(ver))), + uintptr(len(ver)), uintptr(unsafe.Pointer(&verR)), dtCenter|dtSingleLine) + + endPaint.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&ps))) +} + +var ( + procEllipse = gdi32DLL.NewProc("Ellipse") + procCreatePen = gdi32DLL.NewProc("CreatePen") + procRoundRect = gdi32DLL.NewProc("RoundRect") +) + +func drawButton(dis *drawItemStruct) { + hdc := dis.HDC + rc := dis.RcItem + disabled := dis.ItemState&0x0004 != 0 + isUninstall := dis.HwndItem == state.hUninstallBtn + isAllUsers := dis.HwndItem == state.hAllBtn + showShield := (isAllUsers || (isUninstall && state.uninstallNeedsAdmin)) && state.hShieldIcon != 0 + isAgree := dis.HwndItem == state.hAgree + + // --- Special drawing for the agree checkbox --- + if isAgree { + drawAgreeCheckbox(uintptr(hdc), rc) + return + } + + bgColor := uintptr(clrBtnBg) + txtColor := uintptr(clrBtnText) + if isUninstall { + bgColor = clrBtnRed + txtColor = clrText + if disabled { + bgColor = clrBtnRedDis + txtColor = clrSubtext + } + } else if disabled { + bgColor = clrBtnDis + txtColor = clrSubtext + } + + // Clear button area with window bg (so rounded corners don't show square artifacts) + fillRectProc.Call(uintptr(hdc), uintptr(unsafe.Pointer(&rc)), uintptr(state.hBgBrush)) + + // Rounded rectangle background + cornerR := (rc.Bottom - rc.Top) / 4 // corner radius = 25% of button height + brush, _, _ := createSolidBrush.Call(bgColor) + // Need a null pen so RoundRect doesn't draw an outline + nullPen, _, _ := procCreatePen.Call(5, 0, 0) // PS_NULL + oldBrush, _, _ := selectObject.Call(uintptr(hdc), brush) + oldPen2, _, _ := selectObject.Call(uintptr(hdc), nullPen) + procRoundRect.Call(uintptr(hdc), + uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Right), uintptr(rc.Bottom), + uintptr(cornerR), uintptr(cornerR)) + selectObject.Call(uintptr(hdc), oldBrush) + selectObject.Call(uintptr(hdc), oldPen2) + deleteObject.Call(brush) + deleteObject.Call(nullPen) + + setBkMode.Call(uintptr(hdc), transparent) + setTextColor.Call(uintptr(hdc), txtColor) + selectObject.Call(uintptr(hdc), uintptr(state.hNormalFont)) + + buf := make([]uint16, 256) + getWindowTextW.Call(uintptr(dis.HwndItem), uintptr(unsafe.Pointer(&buf[0])), 256) + text := syscall.UTF16ToString(buf) + + // Shield icon size relative to button height + shieldSz := (rc.Bottom - rc.Top) * 40 / 100 + if shieldSz < 16 { + shieldSz = 16 + } + if showShield { + // Measure text width to place shield right beside it + var measureR rect + drawTextW.Call(uintptr(hdc), uintptr(unsafe.Pointer(utf16Ptr(text))), + uintptr(len(text)), uintptr(unsafe.Pointer(&measureR)), + dtSingleLine|0x00000400) // DT_CALCRECT + + // Total width = text + gap + shield, centered in button + gap := shieldSz / 3 + totalW := measureR.Right + gap + shieldSz + startX := rc.Left + (rc.Right-rc.Left-totalW)/2 + + // Draw text + textRc := rect{startX, rc.Top, startX + measureR.Right, rc.Bottom} + drawTextW.Call(uintptr(hdc), uintptr(unsafe.Pointer(utf16Ptr(text))), + uintptr(len(text)), uintptr(unsafe.Pointer(&textRc)), dtSingleLine|dtVCenter) + + // Draw shield right after text + shieldX := startX + measureR.Right + gap + shieldY := rc.Top + (rc.Bottom-rc.Top-shieldSz)/2 + drawIconEx.Call(uintptr(hdc), uintptr(shieldX), uintptr(shieldY), + uintptr(state.hShieldIcon), uintptr(shieldSz), uintptr(shieldSz), 0, 0, 3) + } else { + drawTextW.Call(uintptr(hdc), uintptr(unsafe.Pointer(utf16Ptr(text))), + uintptr(len(text)), uintptr(unsafe.Pointer(&rc)), dtCenter|dtSingleLine|dtVCenter) + } +} + +func drawAgreeCheckbox(hdc uintptr, rc rect) { + h := rc.Bottom - rc.Top + // Fill background + fillRectProc.Call(hdc, uintptr(unsafe.Pointer(&rc)), uintptr(state.hBgBrush)) + + setBkMode.Call(hdc, transparent) + selectObject.Call(hdc, uintptr(state.hNormalFont)) + + // Draw circle (radio indicator) — scaled to row height + circleR := h * 25 / 100 // radius (compact) + circleCX := rc.Left + circleR + h*15/100 + circleCY := rc.Top + h/2 + + // Circle outline — 3px thick for visibility + penWidth := uintptr(3) + if circleR > 12 { + penWidth = 4 + } + pen, _, _ := procCreatePen.Call(0, penWidth, clrAccent) // PS_SOLID, thick, accent color + oldPen, _, _ := selectObject.Call(hdc, pen) + if state.agreeChecked { + // Filled circle + br, _, _ := createSolidBrush.Call(clrAccent) + oldBr, _, _ := selectObject.Call(hdc, br) + procEllipse.Call(hdc, + uintptr(circleCX-circleR), uintptr(circleCY-circleR), + uintptr(circleCX+circleR), uintptr(circleCY+circleR)) + selectObject.Call(hdc, oldBr) + deleteObject.Call(br) + } else { + // Empty circle + nullBrush, _, _ := gdi32DLL.NewProc("GetStockObject").Call(5) // HOLLOW_BRUSH + oldBr, _, _ := selectObject.Call(hdc, nullBrush) + procEllipse.Call(hdc, + uintptr(circleCX-circleR), uintptr(circleCY-circleR), + uintptr(circleCX+circleR), uintptr(circleCY+circleR)) + selectObject.Call(hdc, oldBr) + } + selectObject.Call(hdc, oldPen) + deleteObject.Call(pen) + + // Text starts after circle + textX := circleCX + circleR + h*15/100 + + // "I Agree to the " in normal text color + setTextColor.Call(hdc, clrText) + agreeStr := "I Agree to the " + agreeR := rect{textX, rc.Top, rc.Right, rc.Bottom} + drawTextW.Call(hdc, uintptr(unsafe.Pointer(utf16Ptr(agreeStr))), + uintptr(len(agreeStr)), uintptr(unsafe.Pointer(&agreeR)), dtSingleLine|dtVCenter) + + // Measure "I Agree to the " width to position the link text + var measureR rect + drawTextW.Call(hdc, uintptr(unsafe.Pointer(utf16Ptr(agreeStr))), + uintptr(len(agreeStr)), uintptr(unsafe.Pointer(&measureR)), dtSingleLine|0x00000400) // DT_CALCRECT + + // "Terms and Conditions" in accent color — immediately after + termsX := textX + measureR.Right + setTextColor.Call(hdc, clrAccent) + termsStr := "Terms and Conditions" + termsR := rect{termsX, rc.Top, rc.Right, rc.Bottom} + drawTextW.Call(hdc, uintptr(unsafe.Pointer(utf16Ptr(termsStr))), + uintptr(len(termsStr)), uintptr(unsafe.Pointer(&termsR)), dtSingleLine|dtVCenter) + + // Store the terms link X boundary for hit-testing + state.termsLinkX = termsX +} + +func drawProgressBar(dis *drawItemStruct) { + hdc := uintptr(dis.HDC) + rc := dis.RcItem + cornerR := (rc.Bottom - rc.Top) / 3 + + // Dark background track + bgBrush, _, _ := createSolidBrush.Call(0x00222222) // very dark gray + nullPen, _, _ := procCreatePen.Call(5, 0, 0) // PS_NULL + oldBr, _, _ := selectObject.Call(hdc, bgBrush) + oldPn, _, _ := selectObject.Call(hdc, nullPen) + procRoundRect.Call(hdc, + uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Right), uintptr(rc.Bottom), + uintptr(cornerR), uintptr(cornerR)) + + // Filled portion + if state.progressValue > 0 { + fillW := (rc.Right - rc.Left) * int32(state.progressValue) / 100 + if fillW < cornerR*2 { + fillW = cornerR * 2 // minimum width for rounded rect to look right + } + fillBrush, _, _ := createSolidBrush.Call(clrBtnBg) // accent color + selectObject.Call(hdc, fillBrush) + procRoundRect.Call(hdc, + uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Left+fillW), uintptr(rc.Bottom), + uintptr(cornerR), uintptr(cornerR)) + deleteObject.Call(fillBrush) + } + + selectObject.Call(hdc, oldBr) + selectObject.Call(hdc, oldPn) + deleteObject.Call(bgBrush) + deleteObject.Call(nullPen) +} + +func setProgress(pct int) { + state.progressValue = pct + invalidateRect.Call(uintptr(state.hProgress), 0, 1) +} + +// --- Command handling --- + +func handleCommand(id, notif int) { + switch id { + case idcAgree: + // Toggle agree state and check if they clicked on the "Terms" portion + state.agreeChecked = !state.agreeChecked + invalidateRect.Call(uintptr(state.hAgree), 0, 1) + // Get cursor position to check if they clicked on the link text + var pt struct{ X, Y int32 } + user32DLL.NewProc("GetCursorPos").Call(uintptr(unsafe.Pointer(&pt))) + user32DLL.NewProc("ScreenToClient").Call(uintptr(state.hAgree), uintptr(unsafe.Pointer(&pt))) + if state.termsLinkX > 0 { + // Account for the agree button's position in the parent + var agreeRect rect + user32DLL.NewProc("GetWindowRect").Call(uintptr(state.hAgree), uintptr(unsafe.Pointer(&agreeRect))) + var parentRect rect + user32DLL.NewProc("GetWindowRect").Call(uintptr(state.hwnd), uintptr(unsafe.Pointer(&parentRect))) + localTermsX := state.termsLinkX - (agreeRect.Left - parentRect.Left) + if pt.X >= localTermsX { + // Clicked on "Terms and Conditions" — open dialog + // Undo the toggle since this was a link click, not an agree click + state.agreeChecked = !state.agreeChecked + invalidateRect.Call(uintptr(state.hAgree), 0, 1) + showScrollableDialog("Terms and Conditions", termsAndLicensesText) + } + } + updateButtonState() + case idcUninstall: + if notif == bnClicked { + go onUninstallClicked() // Run in goroutine to keep UI responsive + } + case idcUser: + if notif == bnClicked { + go doInstallFromGUI(modeUser) + } + case idcAll: + if notif == bnClicked { + go doInstallForAllUsers() + } + case idcClose: + postQuitMessage.Call(0) + case idcLaunch: + // Accept any notification — owner-drawn buttons may send different codes + exePath := filepath.Join(state.installedDir, "FastFlix.exe") + if state.installedDir == "" { + messageBox("Error", "Install directory not set", 0x10) + return + } + if _, err := os.Stat(exePath); err != nil { + messageBox("Error", "FastFlix.exe not found at:\n"+exePath, 0x10) + return + } + // Launch via cmd.exe /c start "" "path" to get a fully detached process with console + cmd := fmt.Sprintf(`/c start "" "%s"`, exePath) + cmdPtr, _ := syscall.UTF16PtrFromString(cmd) + exeCmd, _ := syscall.UTF16PtrFromString("cmd.exe") + dirPtr, _ := syscall.UTF16PtrFromString(state.installedDir) + shell32 := syscall.NewLazyDLL("shell32.dll") + shell32.NewProc("ShellExecuteW").Call( + 0, 0, // NULL verb = "open" + uintptr(unsafe.Pointer(exeCmd)), + uintptr(unsafe.Pointer(cmdPtr)), + uintptr(unsafe.Pointer(dirPtr)), + 0) // SW_HIDE for the cmd, FastFlix opens its own window + postQuitMessage.Call(0) + } +} + +func updateButtonState() { + agreed := state.agreeChecked + + // Uninstall button + if state.existingFound && !state.previousRemoved { + can := agreed && !state.processRunning && !state.installing + e := uintptr(0) + if can { + e = 1 + } + enableWindow.Call(uintptr(state.hUninstallBtn), e) + invalidateRect.Call(uintptr(state.hUninstallBtn), 0, 1) + } + + // Install buttons + canInstall := agreed && !state.processRunning && !state.installing && !state.existingFound + e := uintptr(0) + if canInstall { + e = 1 + } + enableWindow.Call(uintptr(state.hUserBtn), e) + enableWindow.Call(uintptr(state.hAllBtn), e) + invalidateRect.Call(uintptr(state.hUserBtn), 0, 1) + invalidateRect.Call(uintptr(state.hAllBtn), 0, 1) +} + +func onUninstallClicked() { + // Re-check if FastFlix is running right now + if isProcessRunning("FastFlix.exe") { + state.processRunning = true + showWindow.Call(uintptr(state.hWarning), swShow) + enableWindow.Call(uintptr(state.hUninstallBtn), 0) + invalidateRect.Call(uintptr(state.hUninstallBtn), 0, 1) + messageBox("FastFlix Running", + "FastFlix is currently running.\n\nPlease close FastFlix before uninstalling.", 0x30) + return + } + + enableWindow.Call(uintptr(state.hUninstallBtn), 0) + setText(state.hUninstallBtn, "Uninstalling...") + invalidateRect.Call(uintptr(state.hUninstallBtn), 0, 1) + + installs := findAllExistingInstalls() + + // Check upfront if ANY install is in a protected path — request UAC once + needsAdmin := false + for _, inst := range installs { + if inst.InstallDir != "" && isProtectedPath(inst.InstallDir) { + needsAdmin = true + break + } + } + if needsAdmin && !isAdmin() { + // Build a single elevated cmd that removes all protected directories + registry + shortcuts + var cmds []string + for _, inst := range installs { + if inst.InstallDir != "" && isProtectedPath(inst.InstallDir) { + cmds = append(cmds, fmt.Sprintf(`rmdir /s /q "%s"`, inst.InstallDir)) + } + } + // Also remove common Start Menu + commonSM := filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + cmds = append(cmds, fmt.Sprintf(`rmdir /s /q "%s"`, commonSM)) + // Also clean HKLM registry keys via reg.exe + cmds = append(cmds, fmt.Sprintf(`reg delete "HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\%s" /f`, productName)) + cmds = append(cmds, fmt.Sprintf(`reg delete "HKLM\SOFTWARE\%s" /f`, productName)) + + cmdStr := strings.Join(cmds, " & ") + verb, _ := syscall.UTF16PtrFromString("runas") + exe, _ := syscall.UTF16PtrFromString("cmd.exe") + argPtr, _ := syscall.UTF16PtrFromString("/c " + cmdStr) + cwdPtr, _ := syscall.UTF16PtrFromString(".") + shell32 := syscall.NewLazyDLL("shell32.dll") + retUAC, _, _ := shell32.NewProc("ShellExecuteW").Call(0, + uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(exe)), + uintptr(unsafe.Pointer(argPtr)), uintptr(unsafe.Pointer(cwdPtr)), 0) + + if retUAC <= 32 { + // User cancelled UAC — show error and stay on uninstall page + messageBox("Administrator Access Required", + "The previous installation is in a protected location (Program Files).\n\nAdministrator access is required to remove it. Please click Uninstall again and accept the permission prompt.", + 0x30) + resetUninstallButton() + return + } + + setText(state.hUninstallBtn, "Removing (elevated)...") + invalidateRect.Call(uintptr(state.hUninstallBtn), 0, 1) + + // Give the elevated process time to start before polling + sleepMs(3000) + + // Wait for protected directories to disappear (up to 60 seconds) + for i := 0; i < 60; i++ { + allGone := true + for _, inst := range installs { + if inst.InstallDir != "" && isProtectedPath(inst.InstallDir) { + if _, err := os.Stat(inst.InstallDir); err == nil { + allGone = false + } + } + } + if allGone { + break + } + sleepMs(1000) + } + // Extra wait for registry cleanup to complete + sleepMs(2000) + } + + // Clean up all installs directly — we don't call existing uninstallers + // because they may be old installer builds that show UI or hit mutex issues. + // cleanupInstallation handles: dirs, shortcuts, desktop icons, registry. + for _, inst := range installs { + cleanupInstallation(inst) + } + + // For protected paths that were removed by elevated cmd, also verify + // the directory is actually gone before checking registry + for _, inst := range installs { + if inst.InstallDir != "" && isProtectedPath(inst.InstallDir) { + // If directory is gone, the elevated cleanup succeeded + if _, err := os.Stat(inst.InstallDir); err != nil { + // Dir is gone — also clean any remaining Start Menu shortcuts we can access + userSM := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + os.RemoveAll(userSM) + } + } + } + + // Final verify — only transition if installs are actually gone + remaining := findAllExistingInstalls() + if len(remaining) > 0 { + // Try one more cleanup pass + for _, inst := range remaining { + cleanupInstallation(inst) + } + remaining = findAllExistingInstalls() + } + + if len(remaining) > 0 { + // Check if it's just orphaned registry (dirs actually gone) vs real remaining files + var realRemaining []string + for _, inst := range remaining { + if inst.InstallDir != "" { + if _, err := os.Stat(inst.InstallDir); err == nil { + // Directory still exists on disk + realRemaining = append(realRemaining, inst.InstallDir) + } + } + } + + if len(realRemaining) > 0 { + // Directories still exist — show error + msg := "The following could not be fully removed:\n\n" + strings.Join(realRemaining, "\n") + msg += "\n\nThis may require administrator permissions or the files may be in use." + msg += "\nPlease try again or remove manually." + messageBox("Uninstall Incomplete", msg, 0x30) + resetUninstallButton() + return + } + // Dirs are gone but registry lingers — clean it up and continue + for _, root := range []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} { + registry.DeleteKey(root, `Software\Microsoft\Windows\CurrentVersion\Uninstall\`+productName) + registry.DeleteKey(root, `SOFTWARE\`+productName) + } + } + + // Uninstall succeeded — show agree checkbox + install buttons + state.previousRemoved = true + state.existingFound = false + showWindow.Call(uintptr(state.hUninstallBtn), swHide) + showWindow.Call(uintptr(state.hInstallInfo), swHide) + showWindow.Call(uintptr(state.hAgree), swShow) + showWindow.Call(uintptr(state.hUserBtn), swShow) + showWindow.Call(uintptr(state.hAllBtn), swShow) + updateButtonState() +} + +func resetUninstallButton() { + resetText := "Uninstall previous version" + if len(state.existingInstalls) > 1 { + resetText = "Uninstall previous versions" + } + setText(state.hUninstallBtn, resetText) + enableWindow.Call(uintptr(state.hUninstallBtn), 1) + invalidateRect.Call(uintptr(state.hUninstallBtn), 0, 1) +} + +func checkProcessTimer() { + running := isProcessRunning("FastFlix.exe") + if running != state.processRunning { + state.processRunning = running + if running { + showWindow.Call(uintptr(state.hWarning), swShow) + } else { + showWindow.Call(uintptr(state.hWarning), swHide) + } + updateButtonState() + } +} + +func detectExistingInstall() { + installs := findAllExistingInstalls() + if len(installs) == 0 { + return + } + + // Filter: only keep installs where the directory has a real FastFlix install + // (not just a leftover uninstall.exe from incomplete self-deletion) + var real []existingInstall + for _, inst := range installs { + if inst.InstallDir != "" { + ffExe := filepath.Join(inst.InstallDir, "FastFlix.exe") + pythonDir := filepath.Join(inst.InstallDir, "python") + if _, err := os.Stat(ffExe); err == nil { + real = append(real, inst) + } else if _, err := os.Stat(pythonDir); err == nil { + real = append(real, inst) + } + // Dir with only uninstall.exe is a leftover — clean it up + if _, err := os.Stat(inst.InstallDir); err == nil { + if _, err2 := os.Stat(ffExe); err2 != nil { + if _, err3 := os.Stat(pythonDir); err3 != nil { + os.RemoveAll(inst.InstallDir) + } + } + } + } + } + + // If all were orphaned registry keys (dirs gone), clean them up silently + if len(real) == 0 { + cleanAllRegistryKeys() + return // No actual installs on disk — skip uninstall screen + } + + state.existingFound = true + state.existingInstalls = real + state.existingDir = real[0].InstallDir + for _, inst := range real { + if inst.Version != "" { + state.existingVersion = inst.Version + break + } + } + for _, inst := range real { + if inst.InstallDir != "" && isProtectedPath(inst.InstallDir) { + state.uninstallNeedsAdmin = true + break + } + } +} + +// buildInstallInfoText creates a human-readable summary of where previous installs were found. +func buildInstallInfoText() string { + var lines []string + programFiles := strings.ToLower(os.Getenv("ProgramFiles")) + localAppData := strings.ToLower(os.Getenv("LOCALAPPDATA")) + + for _, inst := range state.existingInstalls { + ver := inst.Version + if ver == "" { + ver = "unknown version" + } + dirLower := strings.ToLower(inst.InstallDir) + location := inst.InstallDir + if programFiles != "" && strings.HasPrefix(dirLower, programFiles) { + location = "Program Files" + } else if localAppData != "" && strings.HasPrefix(dirLower, localAppData) { + location = "Local Apps" + } + lines = append(lines, fmt.Sprintf("%s found in %s", ver, location)) + } + return strings.Join(lines, "\n") +} + +func doInstallFromGUI(mode installMode) { + state.installing = true + updateButtonState() + + // Hide controls, show progress + for _, h := range []syscall.Handle{state.hAgree, state.hUserBtn, state.hAllBtn, state.hWarning, state.hUninstallBtn, state.hInstallInfo, state.hCloseBtn} { + showWindow.Call(uintptr(h), swHide) + } + showWindow.Call(uintptr(state.hProgress), swShow) + showWindow.Call(uintptr(state.hProgLabel), swShow) + setText(state.hProgLabel, "Installing...") + + installDir, regRoot, startMenu := installPaths(mode) + os.MkdirAll(installDir, 0755) + + lastPct := -1 + err := extractTarZstd(distArchive, installDir, func(current, total int) { + pct := (current * 95) / total // 0-95% for extraction + if pct != lastPct { // only update on actual change + lastPct = pct + setProgress(pct) + setText(state.hProgLabel, fmt.Sprintf("Installing... %d%%", pct)) + } + }) + if err != nil { + messageBox("Installation Failed", "Failed to extract files:\n"+err.Error(), 0x10) + postQuitMessage.Call(0) + return + } + + setText(state.hProgLabel, "Finalizing...") + setProgress(95) + + writeRegistry(installDir, regRoot) + writeInstalledSize(installDir, regRoot) + createShortcuts(installDir, startMenu) + + setProgress(100) + setText(state.hProgLabel, "Installation complete!") + + // Show Launch button instead of closing + state.installedDir = installDir + showWindow.Call(uintptr(state.hLaunchBtn), swShow) +} + +// doInstallForAllUsers triggers UAC immediately, then the elevated process does everything. +// The GUI stays visible and polls for completion. +func doInstallForAllUsers() { + state.installing = true + updateButtonState() + + installDir, _, _ := installPaths(modeAllUsers) + selfPath, _ := os.Executable() + + // Create a temp dir for the done marker + tempDir, err := os.MkdirTemp("", "fastflix-install-*") + if err != nil { + messageBox("Installation Failed", "Failed to create temp directory:\n"+err.Error(), 0x10) + return + } + doneMarker := filepath.Join(tempDir, ".done") + + // Trigger UAC IMMEDIATELY — elevated process does extract + registry + shortcuts + verb, _ := syscall.UTF16PtrFromString("runas") + exe, _ := syscall.UTF16PtrFromString(selfPath) + cmdArgs := fmt.Sprintf(`--elevated-install --dest "%s" --marker "%s"`, installDir, doneMarker) + argPtr, _ := syscall.UTF16PtrFromString(cmdArgs) + cwd, _ := syscall.UTF16PtrFromString(".") + + shell32 := syscall.NewLazyDLL("shell32.dll") + shellExec := shell32.NewProc("ShellExecuteW") + ret, _, _ := shellExec.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(exe)), + uintptr(unsafe.Pointer(argPtr)), uintptr(unsafe.Pointer(cwd)), 0) + if ret <= 32 { + // User cancelled UAC or error + os.RemoveAll(tempDir) + state.installing = false + updateButtonState() + return + } + + // UAC accepted — now show progress + for _, h := range []syscall.Handle{state.hAgree, state.hUserBtn, state.hAllBtn, state.hWarning, state.hUninstallBtn, state.hInstallInfo, state.hCloseBtn} { + showWindow.Call(uintptr(h), swHide) + } + showWindow.Call(uintptr(state.hProgress), swShow) + showWindow.Call(uintptr(state.hProgLabel), swShow) + setText(state.hProgLabel, "Installing to Program Files...") + + // Poll for completion with smooth animated progress + // Uses an ease-out curve: fast at start, slows as it approaches 95% + for i := 1; i <= 600; i++ { // up to 5 minutes (500ms intervals) + if _, err := os.Stat(doneMarker); err == nil { + break + } + // Ease-out: progress = 95 * (1 - e^(-i/30)) + // At i=15 (~7.5s): ~35%, i=30 (~15s): ~55%, i=60 (~30s): ~76%, i=90 (~45s): ~86% + progress := 95.0 * (1.0 - math.Exp(-float64(i)/30.0)) + pct := int(progress) + if pct > 95 { + pct = 95 + } + setProgress(pct) + setText(state.hProgLabel, fmt.Sprintf("Installing to Program Files... %d%%", pct)) + sleepMs(500) + } + + os.RemoveAll(tempDir) + + setProgress(100) + setText(state.hProgLabel, "Installation complete!") + + state.installedDir = installDir + showWindow.Call(uintptr(state.hLaunchBtn), swShow) +} + +// --- Scrollable text dialog --- + +var dlgState struct { + hEdit syscall.Handle + hClose syscall.Handle +} + +func showScrollableDialog(title, content string) { + hInst := state.hInstance + className := utf16Ptr("FastFlixDialog") + cursor, _, _ := user32DLL.NewProc("LoadCursorW").Call(0, uintptr(32512)) + + wc := wndClassExW{ + Size: uint32(unsafe.Sizeof(wndClassExW{})), + Style: 0x0003, + WndProc: syscall.NewCallback(dialogWndProc), + Instance: hInst, + Cursor: syscall.Handle(cursor), + Background: state.hBgBrush, + ClassName: className, + } + registerClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + screenW, _, _ := getSystemMetrics.Call(smCXScreen) + screenH, _, _ := getSystemMetrics.Call(smCYScreen) + // Dialog: 35% of screen width, 50% of screen height + dlgW := int32(screenW) * 35 / 100 + dlgH := int32(screenH) * 50 / 100 + if dlgW < 500 { + dlgW = 500 + } + if dlgH < 400 { + dlgH = 400 + } + posX := (int(screenW) - int(dlgW)) / 2 + posY := (int(screenH) - int(dlgH)) / 2 + dlgPad := dlgW * 2 / 100 // 2% padding + + hwnd, _, _ := createWindowExW.Call(0, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(utf16Ptr(title))), + wsOverlapped|wsCaption|wsSysMenu, + uintptr(posX), uintptr(posY), uintptr(dlgW), uintptr(dlgH), + uintptr(state.hwnd), 0, uintptr(hInst), 0) + + var dark int32 = 1 + dwmSetWindowAttribute.Call(hwnd, 20, uintptr(unsafe.Pointer(&dark)), 4) + + // Edit control fills most of the dialog + btnH := dlgH * 7 / 100 + editH := dlgH - dlgPad*2 - btnH - dlgPad*3 + dlgState.hEdit = createCtl("EDIT", "", + wsChild|wsVisible|wsVScroll|wsBorder|esMultiline|esReadOnly|esAutoVScroll|wsTabStop, + dlgPad, dlgPad, dlgW-dlgPad*2-16, editH, idcDlgEdit, hwnd, uintptr(hInst)) + setFont(dlgState.hEdit, state.hSmallFont) + editMargin := dlgW * 2 / 100 + sendMessageW.Call(uintptr(dlgState.hEdit), emSetMargins, 3, uintptr(editMargin|(editMargin<<16))) + sendMessageW.Call(uintptr(dlgState.hEdit), wmSetText, 0, uintptr(unsafe.Pointer(utf16Ptr(content)))) + + // Close button centered at bottom + closeBtnW := dlgW * 25 / 100 + dlgState.hClose = createCtl("BUTTON", "Close", + wsChild|wsVisible|wsTabStop|bsOwnerDraw, + (dlgW-closeBtnW)/2, dlgPad+editH+dlgPad, closeBtnW, btnH, idcDlgClose, hwnd, uintptr(hInst)) + + showWindow.Call(hwnd, swShow) + updateWindow.Call(hwnd) + enableWindow.Call(uintptr(state.hwnd), 0) + + var m msg + for { + r, _, _ := getMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if r == 0 { + break + } + alive, _, _ := isWindowProc.Call(hwnd) + if alive == 0 { + break + } + translateMessage.Call(uintptr(unsafe.Pointer(&m))) + dispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } + + enableWindow.Call(uintptr(state.hwnd), 1) + setForegroundWindow.Call(uintptr(state.hwnd)) +} + +func dialogWndProc(hwnd syscall.Handle, m uint32, wParam, lParam uintptr) uintptr { + switch m { + case wmCtlColorStatic, wmCtlColorEdit: + hdc := syscall.Handle(wParam) + setTextColor.Call(uintptr(hdc), clrText) + // IMPORTANT: Use OPAQUE mode for EDIT controls, not TRANSPARENT. + // TRANSPARENT causes garbled text as redraws paint over previous text. + setBkMode.Call(uintptr(hdc), 2) // OPAQUE + gdi32DLL.NewProc("SetBkColor").Call(uintptr(hdc), clrEditBg) + return uintptr(state.hEditBrush) + case wmDrawItem: + drawButton((*drawItemStruct)(unsafe.Pointer(lParam))) + return 1 + case wmCommand: + if int(wParam&0xFFFF) == idcDlgClose { + destroyWindow.Call(uintptr(hwnd)) + return 0 + } + case wmClose: + destroyWindow.Call(uintptr(hwnd)) + return 0 + case wmDestroy: + postQuitMessage.Call(0) + return 0 + } + ret, _, _ := defWindowProcW.Call(uintptr(hwnd), uintptr(m), wParam, lParam) + return ret +} diff --git a/cmd/installer/uninstall.go b/cmd/installer/uninstall.go new file mode 100644 index 00000000..38003f22 --- /dev/null +++ b/cmd/installer/uninstall.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +// runUninstall performs the uninstallation for the given mode. +// Pass --silent in os.Args to skip confirmation dialogs (used when called from installer). +func runUninstall(mode installMode) { + silent := containsArg(os.Args[1:], "--silent") || containsArg(os.Args[1:], "/S") + + regRoot := registry.CURRENT_USER + if mode == modeAllUsers { + if !isAdmin() { + args := []string{"--uninstall", "--allusers"} + if silent { + args = append(args, "--silent") + } + relaunchAsAdmin(args...) + os.Exit(0) + } + regRoot = registry.LOCAL_MACHINE + } + + // Find install directory from registry + installDir := "" + appKey, err := registry.OpenKey(regRoot, `SOFTWARE\`+productName, registry.QUERY_VALUE) + if err == nil { + installDir, _, _ = appKey.GetStringValue("Install_Dir") + appKey.Close() + } + + if installDir == "" { + if !silent { + messageBox("Uninstall Error", productName+" installation not found.", 0x10) + } + return + } + + if !silent { + ret := messageBox("Uninstall "+productName, + fmt.Sprintf("Remove %s from:\n%s\n\nContinue?", productName, installDir), + 0x01|0x40) + if ret != 1 { + return + } + } + + // Determine Start Menu path + startMenu := filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + if mode == modeAllUsers { + startMenu = filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", productName) + } + + // Remove Start Menu shortcuts + os.RemoveAll(startMenu) + + // Remove registry keys + registry.DeleteKey(regRoot, `Software\Microsoft\Windows\CurrentVersion\Uninstall\`+productName) + registry.DeleteKey(regRoot, `SOFTWARE\`+productName) + + // Remove installation directory + selfPath, _ := os.Executable() + cleanInstallDir := filepath.Clean(installDir) + cleanSelfPath := filepath.Clean(selfPath) + + // Check if we're running from inside the install directory + runningFromInstallDir := strings.HasPrefix(strings.ToLower(cleanSelfPath), strings.ToLower(cleanInstallDir)) + + if runningFromInstallDir { + // Delete everything except our own exe, then schedule self-deletion + filepath.Walk(cleanInstallDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + cleanPath := filepath.Clean(path) + if cleanPath == cleanSelfPath || cleanPath == cleanInstallDir { + return nil + } + if info.IsDir() { + os.RemoveAll(path) + return filepath.SkipDir + } + os.Remove(path) + return nil + }) + + // Schedule self-deletion after exit + delCmd := fmt.Sprintf( + `ping 127.0.0.1 -n 4 >nul & del /f /q "%s" & rmdir /s /q "%s"`, + cleanSelfPath, cleanInstallDir) + cmd := exec.Command("cmd", "/c", delCmd) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + cmd.Start() + } else { + // We're the installer exe, not inside the install dir — just remove it + os.RemoveAll(cleanInstallDir) + } + + if !silent { + messageBox("Uninstall Complete", productName+" has been removed.", 0x40) + } +} + +// runExistingUninstaller runs the uninstaller from a previous installation. +func runExistingUninstaller(uninstallStr string) { + // Clean the uninstall string (may have quotes and flags) + uninstallStr = strings.TrimSpace(uninstallStr) + + // Parse the command — it might be: "C:\path\uninstall.exe" --uninstall --user + var exe string + var args []string + + if strings.HasPrefix(uninstallStr, `"`) { + // Quoted path + end := strings.Index(uninstallStr[1:], `"`) + if end >= 0 { + exe = uninstallStr[1 : end+1] + remaining := strings.TrimSpace(uninstallStr[end+2:]) + if remaining != "" { + args = strings.Fields(remaining) + } + } + } else { + parts := strings.Fields(uninstallStr) + if len(parts) > 0 { + exe = parts[0] + args = parts[1:] + } + } + + if exe == "" { + return + } + + // Add silent flags so the uninstaller doesn't show UI + hasFlag := func(flag string) bool { + for _, a := range args { + if strings.EqualFold(a, flag) { + return true + } + } + return false + } + // NSIS uses /S, our Go uninstaller uses --silent + if !hasFlag("/S") { + args = append(args, "/S") + } + if !hasFlag("--silent") { + args = append(args, "--silent") + } + + cmd := exec.Command(exe, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} // CREATE_NO_WINDOW + cmd.Run() +} + +// messageBox shows a Windows message box and returns the button clicked. +func messageBox(title, msg string, flags uintptr) int { + user32 := syscall.NewLazyDLL("user32.dll") + proc := user32.NewProc("MessageBoxW") + titlePtr, _ := syscall.UTF16PtrFromString(title) + msgPtr, _ := syscall.UTF16PtrFromString(msg) + ret, _, _ := proc.Call(0, + uintptr(unsafe.Pointer(msgPtr)), + uintptr(unsafe.Pointer(titlePtr)), + flags) + return int(ret) +} diff --git a/cmd/installer/winres/icon.ico b/cmd/installer/winres/icon.ico new file mode 100644 index 00000000..7fe27dfb Binary files /dev/null and b/cmd/installer/winres/icon.ico differ diff --git a/cmd/installer/winres/winres.json b/cmd/installer/winres/winres.json new file mode 100644 index 00000000..882c8aee --- /dev/null +++ b/cmd/installer/winres/winres.json @@ -0,0 +1,44 @@ +{ + "RT_GROUP_ICON": { + "#1": { + "0000": "icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "FastFlix.Installer", + "version": "6.3.0.0" + }, + "description": "FastFlix Installer", + "minimum-os": "win10", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls": true + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "6.3.0.0", + "product_version": "6.3.0.0" + }, + "info": { + "0409": { + "CompanyName": "Chris Griffith", + "FileDescription": "FastFlix Installer", + "FileVersion": "6.3.0", + "InternalName": "FastFlix.Installer", + "LegalCopyright": "(c) Chris Griffith 2019-2025", + "OriginalFilename": "FastFlix_installer.exe", + "ProductName": "FastFlix", + "ProductVersion": "6.3.0" + } + } + } + } + } +} diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go new file mode 100644 index 00000000..d5a4c6d4 --- /dev/null +++ b/cmd/launcher/main.go @@ -0,0 +1,141 @@ +// FastFlix Launcher +// +// Native Go binary that launches FastFlix via Python's embeddable distribution. +// Replaces PyInstaller to avoid antivirus false positives caused by the +// PyInstaller bootloader's temp-extraction behavior. +// +// Build: go build -ldflags="-s -w" -o FastFlix.exe ./cmd/launcher + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" +) + +// Version is set at build time via -ldflags="-X main.Version=6.3.0" +var Version = "dev" + +func main() { + // Resolve install directory from the launcher's own location + exePath, err := os.Executable() + if err != nil { + fatal("Cannot resolve executable path: %v", err) + } + installDir := filepath.Dir(exePath) + + // Handle CLI flags + if len(os.Args) > 1 { + switch os.Args[1] { + case "--version": + fmt.Printf("FastFlix %s\n", Version) + os.Exit(0) + case "--test": + // Pass --test through to Python for startup validation + runPython(installDir, "python.exe", true) + return + case "--help": + fmt.Printf("FastFlix %s\n", Version) + fmt.Println("Usage: FastFlix.exe [--version] [--test] [--help]") + os.Exit(0) + } + } + + // Normal launch: use python.exe (console visible for log output) + runPython(installDir, "python.exe", false) +} + +func runPython(installDir, pythonBin string, passArgs bool) { + pythonDir := filepath.Join(installDir, "python") + libDir := filepath.Join(installDir, "lib") + pythonExe := filepath.Join(pythonDir, pythonBin) + + // Verify Python exists + if _, err := os.Stat(pythonExe); os.IsNotExist(err) { + fatal("Python not found at %s\nPlease reinstall FastFlix.", pythonExe) + } + + // Build environment + env := os.Environ() + env = setEnv(env, "PYTHONHOME", pythonDir) + env = setEnv(env, "PYTHONPATH", libDir) + env = setEnv(env, "FASTFLIX_BUNDLED", "1") + + // Detect portable mode: fastflix.yaml next to the launcher + portableConfig := filepath.Join(installDir, "fastflix.yaml") + if _, err := os.Stat(portableConfig); err == nil { + env = setEnv(env, "FASTFLIX_PORTABLE", "1") + } + + // Prepend python/ and lib/PySide6/ to PATH for DLL discovery + currentPath := os.Getenv("PATH") + pyside6Dir := filepath.Join(libDir, "PySide6") + newPath := pythonDir + ";" + pyside6Dir + ";" + currentPath + env = setEnv(env, "PATH", newPath) + + // Set QT_PLUGIN_PATH so PySide6 finds its plugins + env = setEnv(env, "QT_PLUGIN_PATH", filepath.Join(pyside6Dir, "plugins")) + + // Build command: python -m fastflix [args...] + args := []string{pythonExe, "-m", "fastflix"} + if passArgs && len(os.Args) > 1 { + args = append(args, os.Args[1:]...) + } + + // Set working directory to install dir so base_path resolves correctly + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = installDir + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + fatal("Failed to start FastFlix: %v", err) + } +} + +// setEnv sets or replaces an environment variable in the env slice. +func setEnv(env []string, key, value string) []string { + prefix := key + "=" + for i, e := range env { + if strings.HasPrefix(strings.ToUpper(e), strings.ToUpper(prefix)) { + env[i] = prefix + value + return env + } + } + return append(env, prefix+value) +} + +func fatal(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + // Try to show a Windows message box since we may not have a console + showMessageBox(msg) + os.Exit(1) +} + +func showMessageBox(msg string) { + user32 := syscall.NewLazyDLL("user32.dll") + messageBoxW := user32.NewProc("MessageBoxW") + + title, _ := syscall.UTF16PtrFromString("FastFlix Error") + text, _ := syscall.UTF16PtrFromString(msg) + + const MB_OK = 0x00000000 + const MB_ICONERROR = 0x00000010 + // MessageBoxW(hWnd, lpText, lpCaption, uType) + messageBoxW.Call( + 0, // NULL hWnd + uintptr(unsafe.Pointer(text)), + uintptr(unsafe.Pointer(title)), + uintptr(MB_OK|MB_ICONERROR), + ) +} diff --git a/cmd/launcher/winres/icon.ico b/cmd/launcher/winres/icon.ico new file mode 100644 index 00000000..7fe27dfb Binary files /dev/null and b/cmd/launcher/winres/icon.ico differ diff --git a/cmd/launcher/winres/winres.json b/cmd/launcher/winres/winres.json new file mode 100644 index 00000000..b661a487 --- /dev/null +++ b/cmd/launcher/winres/winres.json @@ -0,0 +1,44 @@ +{ + "RT_GROUP_ICON": { + "#1": { + "0000": "icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "FastFlix", + "version": "6.3.0.0" + }, + "description": "FastFlix Video Encoder", + "minimum-os": "win10", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls": true + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "6.3.0.0", + "product_version": "6.3.0.0" + }, + "info": { + "0409": { + "CompanyName": "Chris Griffith", + "FileDescription": "FastFlix Video Encoder", + "FileVersion": "6.3.0", + "InternalName": "FastFlix", + "LegalCopyright": "(c) Chris Griffith 2019-2025", + "OriginalFilename": "FastFlix.exe", + "ProductName": "FastFlix", + "ProductVersion": "6.3.0" + } + } + } + } + } +} diff --git a/cmd/uninstaller/main.go b/cmd/uninstaller/main.go new file mode 100644 index 00000000..8605ad3a --- /dev/null +++ b/cmd/uninstaller/main.go @@ -0,0 +1,575 @@ +// FastFlix Standalone Uninstaller +// +// Dark-themed GUI that only knows how to uninstall FastFlix from its own directory. +// Registered with Windows Add/Remove Programs. Does NOT contain installer functionality. +// +// Build: +// cd cmd/uninstaller && go-winres make && cd ../.. +// go build -ldflags="-s -w -H windowsgui" -o uninstall.exe ./cmd/uninstaller + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" +) + +const productName = "FastFlix" + +// Win32 API +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + dwmapi = syscall.NewLazyDLL("dwmapi.dll") + comctl32 = syscall.NewLazyDLL("comctl32.dll") +) + +// State +var st struct { + hwnd syscall.Handle + hInst syscall.Handle + hBgBrush syscall.Handle + hIcon syscall.Handle + hTitleFont syscall.Handle + hFont syscall.Handle + hSmFont syscall.Handle + + hUninstBtn syscall.Handle + hCloseBtn syscall.Handle + hStatus syscall.Handle + hProgress syscall.Handle + hProgLabel syscall.Handle + + installDir string + selfPath string + winW, winH int + iconSz int + silent bool +} + +// Colors +const ( + clrBg = 0x00333333 + clrText = 0x00E6E6E6 + clrSub = 0x00A0A0A0 + clrBtn = 0x004848CC // red + clrBtnDis = 0x00444466 + clrBtnCls = 0x00F0D030 // cyan close + clrBtnTxt = 0x00FFFFFF + clrRed = 0x003C3CDC + clrDarkBg = 0x00222222 +) + +// Control IDs +const ( + idUninstall = 101 + idClose = 102 + idStatus = 103 + idProgress = 104 + idProgLabel = 105 +) + +type rect struct{ Left, Top, Right, Bottom int32 } +type paintStruct struct { + HDC syscall.Handle + Erase int32 + RcPaint rect + Restore int32 + IncUpdate int32 + Reserved [32]byte +} +type msg struct { + Hwnd syscall.Handle + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt struct{ X, Y int32 } +} +type drawItemStruct struct { + CtlType, CtlID, ItemID, ItemAction, ItemState uint32 + HwndItem, HDC syscall.Handle + RcItem rect + ItemData uintptr +} +type wndClassExW struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance syscall.Handle + Icon syscall.Handle + Cursor syscall.Handle + Background syscall.Handle + MenuName *uint16 + ClassName *uint16 + IconSm syscall.Handle +} + +func utf16P(s string) *uint16 { p, _ := syscall.UTF16PtrFromString(s); return p } + +func main() { + runtime.LockOSThread() + args := os.Args[1:] + st.silent = containsArg(args, "--silent") || containsArg(args, "/S") + + var err error + st.selfPath, err = os.Executable() + if err != nil { + os.Exit(1) + } + st.installDir = filepath.Dir(st.selfPath) + + // Verify FastFlix install + if _, err := os.Stat(filepath.Join(st.installDir, "python")); err != nil { + if _, err2 := os.Stat(filepath.Join(st.installDir, "FastFlix.exe")); err2 != nil { + if !st.silent { + msgBox("FastFlix Uninstaller", + "No FastFlix installation found in:\n"+st.installDir+"\n\nIt may have already been removed.", 0x40) + } + os.Exit(0) + } + } + + // Silent mode — just do it and exit + if st.silent { + doUninstall() + return + } + + // If in Program Files and not admin, elevate and re-run with GUI + if isProtected(st.installDir) && !isAdmin() { + relaunchElevated(st.selfPath, args) + return + } + + // Show GUI + showGUI() +} + +func showGUI() { + hInst, _, _ := kernel32.NewProc("GetModuleHandleW").Call(0) + st.hInst = syscall.Handle(hInst) + + screenW, _, _ := user32.NewProc("GetSystemMetrics").Call(0) + screenH, _, _ := user32.NewProc("GetSystemMetrics").Call(1) + st.winW = int(screenW) * 20 / 100 + if st.winW < 400 { st.winW = 400 } + st.winH = st.winW * 150 / 100 + if st.winH > int(screenH)*85/100 { st.winH = int(screenH) * 85 / 100 } + st.iconSz = st.winW * 30 / 100 + if st.iconSz > 256 { st.iconSz = 256 } + + ret, _, _ := gdi32.NewProc("CreateSolidBrush").Call(clrBg) + st.hBgBrush = syscall.Handle(ret) + + titlePt := st.winW * 54 / 660 + normalPt := st.winW * 24 / 660 + smallPt := st.winW * 20 / 660 + st.hTitleFont = mkFont("Segoe UI", titlePt, true) + st.hFont = mkFont("Segoe UI", normalPt, false) + st.hSmFont = mkFont("Segoe UI", smallPt, false) + + ret, _, _ = user32.NewProc("LoadImageW").Call(hInst, 1, 1, uintptr(st.iconSz), uintptr(st.iconSz), 0) + st.hIcon = syscall.Handle(ret) + + cls := utf16P("FastFlixUninstaller") + cur, _, _ := user32.NewProc("LoadCursorW").Call(0, 32512) + wc := wndClassExW{ + Size: uint32(unsafe.Sizeof(wndClassExW{})), Style: 3, + WndProc: syscall.NewCallback(wndProc), Instance: st.hInst, + Icon: st.hIcon, Cursor: syscall.Handle(cur), Background: st.hBgBrush, + ClassName: cls, IconSm: st.hIcon, + } + user32.NewProc("RegisterClassExW").Call(uintptr(unsafe.Pointer(&wc))) + + px := (int(screenW) - st.winW) / 2 + py := (int(screenH) - st.winH) / 2 + hwnd, _, _ := user32.NewProc("CreateWindowExW").Call(0x02000000, + uintptr(unsafe.Pointer(cls)), uintptr(unsafe.Pointer(utf16P("FastFlix Uninstaller"))), + 0x00800000, // WS_BORDER + uintptr(px), uintptr(py), uintptr(st.winW), uintptr(st.winH), 0, 0, hInst, 0) + st.hwnd = syscall.Handle(hwnd) + + var dark int32 = 1 + dwmapi.NewProc("DwmSetWindowAttribute").Call(hwnd, 20, uintptr(unsafe.Pointer(&dark)), 4) + if st.hIcon != 0 { + user32.NewProc("SendMessageW").Call(hwnd, 0x0080, 0, uintptr(st.hIcon)) + user32.NewProc("SendMessageW").Call(hwnd, 0x0080, 1, uintptr(st.hIcon)) + } + + createControls(hwnd, hInst) + + // Check if running on startup + if isRunning("FastFlix.exe") { + setText(st.hStatus, "\u26D4 FastFlix is running, please close it first") + user32.NewProc("ShowWindow").Call(uintptr(st.hStatus), 5) + user32.NewProc("EnableWindow").Call(uintptr(st.hUninstBtn), 0) + inval(st.hUninstBtn) + } + + // Timer to check process every 10s + user32.NewProc("SetTimer").Call(hwnd, 1, 10000, 0) + + user32.NewProc("ShowWindow").Call(hwnd, 5) + user32.NewProc("UpdateWindow").Call(hwnd) + + var m msg + for { + r, _, _ := user32.NewProc("GetMessageW").Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if r == 0 { break } + user32.NewProc("TranslateMessage").Call(uintptr(unsafe.Pointer(&m))) + user32.NewProc("DispatchMessageW").Call(uintptr(unsafe.Pointer(&m))) + } +} + +func createControls(hwnd, hInst uintptr) { + cx := int32(st.winW) + pad := cx * 9 / 100 + btnW := cx - pad*2 + btnH := int32(st.winH) * 7 / 100 + ctlH := int32(st.winH) * 4 / 100 + + // Uninstall button at ~58% + y := int32(st.winH) * 58 / 100 + st.hUninstBtn = mkCtl("BUTTON", "Uninstall FastFlix", 0x50000000|0x0000000B, + pad, y, btnW, btnH, idUninstall, hwnd, hInst) // WS_CHILD|WS_VISIBLE|BS_OWNERDRAW + + y += btnH + ctlH/2 + // Location info + loc := locationLabel() + mkStatic(loc, 0x50000000|0x01, pad, y, btnW, ctlH, hwnd, hInst) // SS_CENTER + + y += ctlH + ctlH/2 + // Status (hidden) + st.hStatus = mkCtl("STATIC", "", 0x40000000|0x01, pad, y, btnW, ctlH*2, idStatus, hwnd, hInst) + setFont(st.hStatus, st.hFont) + + // Progress (hidden) + progY := int32(st.winH) * 58 / 100 + progH := int32(st.winH) * 3 / 100 + st.hProgress = mkCtl("STATIC", "", 0x40000000|0x0000000D, pad, progY, btnW, progH, idProgress, hwnd, hInst) // SS_OWNERDRAW + st.hProgLabel = mkCtl("STATIC", "", 0x40000000|0x01, pad, progY+progH+progH/2, btnW, ctlH, idProgLabel, hwnd, hInst) + setFont(st.hProgLabel, st.hFont) + + // Close button at bottom + closW := cx * 20 / 100 + closH := int32(st.winH) * 4 / 100 + closY := int32(st.winH) - closH - int32(st.winH)*8/100 + st.hCloseBtn = mkCtl("BUTTON", "Close", 0x50000000|0x00010000|0x0000000B, + (cx-closW)/2, closY, closW, closH, idClose, hwnd, hInst) +} + +func locationLabel() string { + dirLower := strings.ToLower(st.installDir) + loc := st.installDir + if strings.Contains(dirLower, "program files") { + loc = "Program Files" + } else if strings.Contains(dirLower, "appdata") { + loc = "Local Apps" + } + return "Installed in " + loc +} + +func wndProc(hwnd syscall.Handle, m uint32, wParam, lParam uintptr) uintptr { + switch m { + case 0x000F: // WM_PAINT + paint(hwnd) + return 0 + case 0x0138: // WM_CTLCOLORSTATIC + hdc := syscall.Handle(wParam) + ctl := syscall.Handle(lParam) + gdi32.NewProc("SetTextColor").Call(uintptr(hdc), clrText) + gdi32.NewProc("SetBkMode").Call(uintptr(hdc), 1) + if ctl == st.hStatus { gdi32.NewProc("SetTextColor").Call(uintptr(hdc), clrRed) } + return uintptr(st.hBgBrush) + case 0x0135: // WM_CTLCOLORBTN + return uintptr(st.hBgBrush) + case 0x002B: // WM_DRAWITEM + dis := (*drawItemStruct)(unsafe.Pointer(lParam)) + if dis.HwndItem == st.hProgress { + drawProgress(dis) + } else { + drawBtn(dis) + } + return 1 + case 0x0111: // WM_COMMAND + id := int(wParam & 0xFFFF) + if id == idClose { user32.NewProc("PostQuitMessage").Call(0) } + if id == idUninstall { go onUninstall() } + return 0 + case 0x0113: // WM_TIMER + checkProc() + return 0 + case 0x0010: // WM_CLOSE + user32.NewProc("DestroyWindow").Call(uintptr(hwnd)) + return 0 + case 0x0002: // WM_DESTROY + user32.NewProc("PostQuitMessage").Call(0) + return 0 + } + r, _, _ := user32.NewProc("DefWindowProcW").Call(uintptr(hwnd), uintptr(m), wParam, lParam) + return r +} + +func paint(hwnd syscall.Handle) { + var ps paintStruct + hdc, _, _ := user32.NewProc("BeginPaint").Call(uintptr(hwnd), uintptr(unsafe.Pointer(&ps))) + var rc rect + user32.NewProc("GetClientRect").Call(uintptr(hwnd), uintptr(unsafe.Pointer(&rc))) + user32.NewProc("FillRect").Call(hdc, uintptr(unsafe.Pointer(&rc)), uintptr(st.hBgBrush)) + + if st.hIcon != 0 { + ix := (int32(st.winW) - int32(st.iconSz)) / 2 + user32.NewProc("DrawIconEx").Call(hdc, uintptr(ix), uintptr(int32(st.winH)*16/100), + uintptr(st.hIcon), uintptr(st.iconSz), uintptr(st.iconSz), 0, 0, 3) + } + + gdi32.NewProc("SetBkMode").Call(hdc, 1) + iconBot := int32(st.winH)*16/100 + int32(st.iconSz) + int32(st.winH)*4/100 + + gdi32.NewProc("SetTextColor").Call(hdc, clrText) + gdi32.NewProc("SelectObject").Call(hdc, uintptr(st.hTitleFont)) + tH := int32(st.winH) * 7 / 100 + tr := rect{0, iconBot, int32(st.winW), iconBot + tH} + user32.NewProc("DrawTextW").Call(hdc, uintptr(unsafe.Pointer(utf16P("FastFlix"))), + uintptr(len("FastFlix")), uintptr(unsafe.Pointer(&tr)), 0x21) // DT_CENTER|DT_SINGLELINE + + gdi32.NewProc("SetTextColor").Call(hdc, clrSub) + gdi32.NewProc("SelectObject").Call(hdc, uintptr(st.hFont)) + vr := rect{0, iconBot + tH, int32(st.winW), iconBot + tH + int32(st.winH)*4/100} + txt := "Uninstaller" + user32.NewProc("DrawTextW").Call(hdc, uintptr(unsafe.Pointer(utf16P(txt))), + uintptr(len(txt)), uintptr(unsafe.Pointer(&vr)), 0x21) + + user32.NewProc("EndPaint").Call(uintptr(hwnd), uintptr(unsafe.Pointer(&ps))) +} + +var progressVal int + +func drawBtn(dis *drawItemStruct) { + hdc := uintptr(dis.HDC) + rc := dis.RcItem + disabled := dis.ItemState&0x0004 != 0 + isClose := dis.HwndItem == st.hCloseBtn + + bg := uintptr(clrBtn) + txt := uintptr(clrBtnTxt) + if isClose { bg = clrBtnCls; txt = 0x00202020 } + if disabled { bg = clrBtnDis; txt = clrSub } + + // Clear + rounded rect + user32.NewProc("FillRect").Call(hdc, uintptr(unsafe.Pointer(&rc)), uintptr(st.hBgBrush)) + cornerR := (rc.Bottom - rc.Top) / 4 + br, _, _ := gdi32.NewProc("CreateSolidBrush").Call(bg) + np, _, _ := gdi32.NewProc("CreatePen").Call(5, 0, 0) + ob, _, _ := gdi32.NewProc("SelectObject").Call(hdc, br) + op, _, _ := gdi32.NewProc("SelectObject").Call(hdc, np) + gdi32.NewProc("RoundRect").Call(hdc, uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Right), uintptr(rc.Bottom), uintptr(cornerR), uintptr(cornerR)) + gdi32.NewProc("SelectObject").Call(hdc, ob) + gdi32.NewProc("SelectObject").Call(hdc, op) + gdi32.NewProc("DeleteObject").Call(br) + gdi32.NewProc("DeleteObject").Call(np) + + gdi32.NewProc("SetBkMode").Call(hdc, 1) + gdi32.NewProc("SetTextColor").Call(hdc, txt) + gdi32.NewProc("SelectObject").Call(hdc, uintptr(st.hFont)) + buf := make([]uint16, 256) + user32.NewProc("GetWindowTextW").Call(uintptr(dis.HwndItem), uintptr(unsafe.Pointer(&buf[0])), 256) + t := syscall.UTF16ToString(buf) + user32.NewProc("DrawTextW").Call(hdc, uintptr(unsafe.Pointer(utf16P(t))), + uintptr(len(t)), uintptr(unsafe.Pointer(&rc)), 0x25) // DT_CENTER|DT_SINGLELINE|DT_VCENTER +} + +func drawProgress(dis *drawItemStruct) { + hdc := uintptr(dis.HDC) + rc := dis.RcItem + cornerR := (rc.Bottom - rc.Top) / 3 + bg, _, _ := gdi32.NewProc("CreateSolidBrush").Call(clrDarkBg) + np, _, _ := gdi32.NewProc("CreatePen").Call(5, 0, 0) + ob, _, _ := gdi32.NewProc("SelectObject").Call(hdc, bg) + op, _, _ := gdi32.NewProc("SelectObject").Call(hdc, np) + gdi32.NewProc("RoundRect").Call(hdc, uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Right), uintptr(rc.Bottom), uintptr(cornerR), uintptr(cornerR)) + if progressVal > 0 { + fillW := (rc.Right - rc.Left) * int32(progressVal) / 100 + if fillW < cornerR*2 { fillW = cornerR * 2 } + fb, _, _ := gdi32.NewProc("CreateSolidBrush").Call(clrBtnCls) + gdi32.NewProc("SelectObject").Call(hdc, fb) + gdi32.NewProc("RoundRect").Call(hdc, uintptr(rc.Left), uintptr(rc.Top), uintptr(rc.Left+fillW), uintptr(rc.Bottom), uintptr(cornerR), uintptr(cornerR)) + gdi32.NewProc("DeleteObject").Call(fb) + } + gdi32.NewProc("SelectObject").Call(hdc, ob) + gdi32.NewProc("SelectObject").Call(hdc, op) + gdi32.NewProc("DeleteObject").Call(bg) + gdi32.NewProc("DeleteObject").Call(np) +} + +func onUninstall() { + if isRunning("FastFlix.exe") { + setText(st.hStatus, "\u26D4 FastFlix is running, please close it first") + user32.NewProc("ShowWindow").Call(uintptr(st.hStatus), 5) + user32.NewProc("EnableWindow").Call(uintptr(st.hUninstBtn), 0) + inval(st.hUninstBtn) + return + } + + // Switch to progress view + user32.NewProc("ShowWindow").Call(uintptr(st.hUninstBtn), 0) + user32.NewProc("ShowWindow").Call(uintptr(st.hCloseBtn), 0) + user32.NewProc("ShowWindow").Call(uintptr(st.hStatus), 0) + user32.NewProc("ShowWindow").Call(uintptr(st.hProgress), 5) + user32.NewProc("ShowWindow").Call(uintptr(st.hProgLabel), 5) + setText(st.hProgLabel, "Uninstalling...") + + doUninstall() + + setProgress(100) + setText(st.hProgLabel, "FastFlix has been removed.") + + // Show close button again + user32.NewProc("ShowWindow").Call(uintptr(st.hCloseBtn), 5) +} + +func doUninstall() { + setProgress(10) + + // Shortcuts + for _, sm := range []string{ + filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", productName), + filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", productName), + } { os.RemoveAll(sm) } + for _, d := range []string{ + filepath.Join(os.Getenv("USERPROFILE"), "Desktop", productName+".lnk"), + filepath.Join(os.Getenv("PUBLIC"), "Desktop", productName+".lnk"), + } { os.Remove(d) } + + setProgress(30) + + // Registry (all paths including WOW6432Node for old 32-bit NSIS installs) + regPaths := []string{ + `Software\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\` + productName, + `SOFTWARE\` + productName, + } + for _, root := range []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} { + for _, p := range regPaths { + registry.DeleteKey(root, p) + } + } + + setProgress(50) + + // Remove files (except self) + cleanSelf := filepath.Clean(st.selfPath) + cleanDir := filepath.Clean(st.installDir) + filepath.Walk(cleanDir, func(path string, info os.FileInfo, err error) error { + if err != nil { return nil } + c := filepath.Clean(path) + if c == cleanSelf || c == cleanDir { return nil } + if info.IsDir() { os.RemoveAll(path); return filepath.SkipDir } + os.Remove(path) + return nil + }) + + setProgress(90) + + // Schedule self + dir deletion — uses a loop to retry until our process exits. + // The /w flag on ping waits 1s per ping. We retry rmdir in a loop. + delCmd := fmt.Sprintf( + `:retry`+"\n"+ + `ping 127.0.0.1 -n 2 >nul`+"\n"+ + `del /f /q "%s" 2>nul`+"\n"+ + `if exist "%s" goto retry`+"\n"+ + `rmdir /s /q "%s"`, + cleanSelf, cleanSelf, cleanDir) + cmd := exec.Command("cmd", "/c", delCmd) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000} + cmd.Start() +} + +func checkProc() { + running := isRunning("FastFlix.exe") + if running { + setText(st.hStatus, "\u26D4 FastFlix is running, please close it first") + user32.NewProc("ShowWindow").Call(uintptr(st.hStatus), 5) + user32.NewProc("EnableWindow").Call(uintptr(st.hUninstBtn), 0) + } else { + user32.NewProc("ShowWindow").Call(uintptr(st.hStatus), 0) + user32.NewProc("EnableWindow").Call(uintptr(st.hUninstBtn), 1) + } + inval(st.hUninstBtn) +} + +// Helpers + +func setProgress(pct int) { + progressVal = pct + inval(st.hProgress) +} + +func setText(h syscall.Handle, s string) { + user32.NewProc("SetWindowTextW").Call(uintptr(h), uintptr(unsafe.Pointer(utf16P(s)))) +} +func setFont(h, f syscall.Handle) { + user32.NewProc("SendMessageW").Call(uintptr(h), 0x0030, uintptr(f), 1) +} +func inval(h syscall.Handle) { + user32.NewProc("InvalidateRect").Call(uintptr(h), 0, 1) +} +func mkFont(fam string, sz int, bold bool) syscall.Handle { + w := int32(400); if bold { w = 700 } + h, _, _ := gdi32.NewProc("CreateFontW").Call(uintptr(uint32(-sz)), 0, 0, 0, uintptr(w), 0, 0, 0, 1, 0, 0, 5, 0, uintptr(unsafe.Pointer(utf16P(fam)))) + return syscall.Handle(h) +} +func mkCtl(cls, txt string, style uint32, x, y, w, h int32, id int, parent, hInst uintptr) syscall.Handle { + r, _, _ := user32.NewProc("CreateWindowExW").Call(0, uintptr(unsafe.Pointer(utf16P(cls))), uintptr(unsafe.Pointer(utf16P(txt))), uintptr(style), uintptr(x), uintptr(y), uintptr(w), uintptr(h), parent, uintptr(id), hInst, 0) + return syscall.Handle(r) +} +func mkStatic(txt string, style uint32, x, y, w, h int32, parent, hInst uintptr) syscall.Handle { + r := mkCtl("STATIC", txt, style, x, y, w, h, 0, parent, hInst) + setFont(r, st.hSmFont) + return r +} + +func containsArg(a []string, f string) bool { + for _, v := range a { if strings.EqualFold(v, f) { return true } }; return false +} +func isRunning(name string) bool { + snap, e := syscall.CreateToolhelp32Snapshot(2, 0); if e != nil { return false } + defer syscall.CloseHandle(snap) + var pe syscall.ProcessEntry32; pe.Size = uint32(unsafe.Sizeof(pe)) + e = syscall.Process32First(snap, &pe) + for e == nil { + if strings.EqualFold(syscall.UTF16ToString(pe.ExeFile[:]), name) { return true } + e = syscall.Process32Next(snap, &pe) + }; return false +} +func isProtected(dir string) bool { + d := strings.ToLower(filepath.Clean(dir)) + for _, e := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} { + r := strings.ToLower(os.Getenv(e)); if r != "" && strings.HasPrefix(d, r) { return true } + }; return false +} +func isAdmin() bool { _, e := os.Open("\\\\.\\PHYSICALDRIVE0"); return e == nil } +func relaunchElevated(self string, args []string) { + v, _ := syscall.UTF16PtrFromString("runas"); e, _ := syscall.UTF16PtrFromString(self) + a, _ := syscall.UTF16PtrFromString(strings.Join(args, " ")); c, _ := syscall.UTF16PtrFromString(filepath.Dir(self)) + syscall.NewLazyDLL("shell32.dll").NewProc("ShellExecuteW").Call(0, uintptr(unsafe.Pointer(v)), uintptr(unsafe.Pointer(e)), uintptr(unsafe.Pointer(a)), uintptr(unsafe.Pointer(c)), 1) +} + +func msgBox(title, msg string, flags uintptr) int { + t, _ := syscall.UTF16PtrFromString(title) + m, _ := syscall.UTF16PtrFromString(msg) + ret, _, _ := user32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags) + return int(ret) +} diff --git a/cmd/uninstaller/winres/icon.ico b/cmd/uninstaller/winres/icon.ico new file mode 100644 index 00000000..7fe27dfb Binary files /dev/null and b/cmd/uninstaller/winres/icon.ico differ diff --git a/cmd/uninstaller/winres/winres.json b/cmd/uninstaller/winres/winres.json new file mode 100644 index 00000000..0749e702 --- /dev/null +++ b/cmd/uninstaller/winres/winres.json @@ -0,0 +1,44 @@ +{ + "RT_GROUP_ICON": { + "#1": { + "0000": "icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "FastFlix.Uninstaller", + "version": "6.3.0.0" + }, + "description": "FastFlix Uninstaller", + "minimum-os": "win10", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls": true + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "6.3.0.0", + "product_version": "6.3.0.0" + }, + "info": { + "0409": { + "CompanyName": "Chris Griffith", + "FileDescription": "FastFlix Uninstaller", + "FileVersion": "6.3.0", + "InternalName": "FastFlix.Uninstaller", + "LegalCopyright": "(c) Chris Griffith 2019-2025", + "OriginalFilename": "uninstall.exe", + "ProductName": "FastFlix", + "ProductVersion": "6.3.0" + } + } + } + } + } +} diff --git a/fastflix/__main__.py b/fastflix/__main__.py index e616c929..a1731fdb 100644 --- a/fastflix/__main__.py +++ b/fastflix/__main__.py @@ -37,11 +37,16 @@ def setup_ocr_environment(): def start_fastflix(): exit_code = 2 - portable_mode = True - try: - from fastflix import portable # noqa: F401 - except ImportError: - portable_mode = False + + # Portable mode: Go launcher sets FASTFLIX_PORTABLE=1, PyInstaller uses portable.py import + portable_mode = os.environ.get("FASTFLIX_PORTABLE") == "1" + if not portable_mode: + try: + from fastflix import portable # noqa: F401 + + portable_mode = True + except ImportError: + pass if portable_mode: print("PORTABLE MODE DETECTED: now using local config file and workspace in same directory as the executable") diff --git a/fastflix/audio_processing.py b/fastflix/audio_processing.py index 4d24a7c5..92ff2fc1 100644 --- a/fastflix/audio_processing.py +++ b/fastflix/audio_processing.py @@ -80,4 +80,33 @@ def apply_audio_filters( else: tracks.extend(subset_tracks) + elif audio_match.match_item == MatchItem.CODEC: + subset_tracks = [] + for track in original_tracks: + if audio_match.match_input.lower() == track.codec_name.lower(): + subset_tracks.append((track, audio_match)) + if subset_tracks: + if audio_match.match_type == MatchType.FIRST: + tracks.append(subset_tracks[0]) + elif audio_match.match_type == MatchType.LAST: + tracks.append(subset_tracks[-1]) + else: + tracks.extend(subset_tracks) + + elif audio_match.match_item == MatchItem.CODEC_PROFILE: + subset_tracks = [] + match_codec, _, match_profile = audio_match.match_input.partition(":") + for track in original_tracks: + if match_codec.lower() == track.codec_name.lower(): + track_profile = track.get("profile", "") or "" + if match_profile.lower() == track_profile.lower(): + subset_tracks.append((track, audio_match)) + if subset_tracks: + if audio_match.match_type == MatchType.FIRST: + tracks.append(subset_tracks[0]) + elif audio_match.match_type == MatchType.LAST: + tracks.append(subset_tracks[-1]) + else: + tracks.extend(subset_tracks) + return sorted(tracks, key=lambda x: x[0].index) diff --git a/fastflix/data/languages.yaml b/fastflix/data/languages.yaml index b9d93652..872f4134 100644 --- a/fastflix/data/languages.yaml +++ b/fastflix/data/languages.yaml @@ -298,6 +298,21 @@ Auto Crop - Finding black bars at: ukr: Автоматичне обрізання - знаходження чорних смуг на kor: 자동 자르기 - 다음에서 검은색 막대 찾기 ron: Auto Crop - Găsirea barelor negre la +Average Frame Rate: + eng: Average Frame Rate + deu: Durchschnittliche Bildrate + fra: Taux de rafraîchissement moyen + ita: Frequenza media dei fotogrammi + spa: Frecuencia de imagen media + jpn: 平均フレームレート + rus: Средняя частота кадров + por: Taxa de quadros média + swe: Genomsnittlig bildfrekvens + pol: Średnia liczba klatek na sekundę + chs: 平均帧频 + ukr: Середня частота кадрів + kor: 평균 프레임 속도 + ron: Rata medie a cadrelor Automatically detect black borders: deu: Automatische Erkennung von schwarzen Rändern eng: Automatically detect black borders @@ -388,6 +403,21 @@ Bit Depth: ukr: Глибина біта kor: 비트 깊이 ron: Adâncimea biților +Bit Rate: + eng: Bit Rate + deu: Bitrate + fra: Débit binaire + ita: Velocità di trasmissione + spa: Tasa de bits + jpn: ビット・レート + rus: Скорость передачи данных + por: Taxa de bits + swe: Bithastighet + pol: Szybkość transmisji + chs: 比特率 + ukr: Бітрейт + kor: 비트 전송률 + ron: Rata de biți Bitrate: deu: Bitrate eng: Bitrate @@ -403,6 +433,36 @@ Bitrate: ukr: Бітрейт kor: 비트 전송률 ron: Bitrate +Bits per Raw Sample: + eng: Bits per Raw Sample + deu: Bits pro Rohsample + fra: Bits par échantillon brut + ita: Bit per campione grezzo + spa: Bits por muestra bruta + jpn: 生サンプルあたりのビット数 + rus: Биты на сырой образец + por: Bits por amostra bruta + swe: Bitar per råprov + pol: Bity na próbkę surową + chs: 每个原始采样的比特 + ukr: Біти на сирий зразок + kor: 원시 샘플당 비트 수 + ron: Biți pe eșantion brut +Bits per Sample: + eng: Bits per Sample + deu: Bits pro Probe + fra: Bits par échantillon + ita: Bit per campione + spa: Bits por muestra + jpn: サンプルあたりのビット数 + rus: Биты на образец + por: Bits por amostra + swe: Bitar per prov + pol: Bity na próbkę + chs: 每个采样比特 + ukr: Біти на зразок + kor: 샘플당 비트 수 + ron: Biți pe eșantion Block Size: deu: Blockgröße eng: Block Size @@ -733,6 +793,21 @@ CodeCalamity UHD HDR Encoding Guide: ukr: Посібник із кодування UHD HDR від CodeCalamity kor: 코드캘러미티 UHD HDR 인코딩 가이드 ron: CodeCalamity Ghid de codare UHD HDR +Chroma Location: + eng: Chroma Location + deu: Chroma Standort + fra: Emplacement du chroma + ita: Posizione di Chroma + spa: Ubicación de Chroma + jpn: クロマ・ロケーション + rus: Расположение хрома + por: Localização do Chroma + swe: Chroma-läge + pol: Lokalizacja Chroma + chs: 色度位置 + ukr: Розташування Chroma + kor: 크로마 위치 + ron: Locația Chroma Color Primaries: deu: Grundfarbmodell eng: Color Primaries @@ -748,6 +823,21 @@ Color Primaries: ukr: Кольорові праймери kor: 색상 원색 ron: Primare de culoare +Color Range: + eng: Color Range + deu: Farbpalette + fra: Gamme de couleurs + ita: Gamma di colori + spa: Gama de colores + jpn: カラー・レンジ + rus: Цветовая гамма + por: Gama de cores + swe: Färgområde + pol: Zakres kolorów + chs: 颜色范围 + ukr: Колірна гамма + kor: 색상 범위 + ron: Gama de culori Color Space: deu: Farbraum eng: Color Space @@ -1558,6 +1648,21 @@ Disposition: ukr: Диспозиція kor: 처분 ron: Dispoziție +Display Aspect Ratio: + eng: Display Aspect Ratio + deu: Seitenverhältnis der Anzeige + fra: Rapport d'aspect de l'écran + ita: Rapporto d'aspetto del display + spa: Relación de aspecto de la pantalla + jpn: ディスプレイのアスペクト比 + rus: Соотношение сторон экрана + por: Relação de aspeto do ecrã + swe: Bildskärmens bildförhållande + pol: Współczynnik proporcji wyświetlacza + chs: 显示屏宽高比 + ukr: Співвідношення сторін дисплея + kor: 화면 비율 표시 + ron: Raportul de aspect al afișajului Dither: deu: Dithern eng: Dither @@ -1603,6 +1708,25 @@ Download: ukr: Завантажити kor: 다운로드 ron: Descărcați +Download JSON: + eng: Download JSON + deu: JSON herunterladen + fra: Télécharger JSON + ita: Scarica JSON + spa: Descargar JSON + jpn: JSONダウンロード + rus: Загрузить JSON + por: Descarregar JSON + swe: Ladda ner JSON + pol: Pobierz JSON + chs: 下载 JSON + ukr: Завантажити JSON + kor: JSON 다운로드 + ron: Descărcați JSON +Download HDR10: + eng: Download HDR10 +Text Files: + eng: Text Files Download Cancelled: deu: Download abgebrochen eng: Download Cancelled @@ -1753,6 +1877,21 @@ Enables the yadif filter.: ukr: Вмикає фільтр yadif. kor: 야디프 필터를 활성화합니다. ron: Activează filtrul yadif. +Enables deinterlacing (method selectable in Advanced panel).: + deu: Aktiviert Deinterlacing (Methode im Erweitert-Panel auswählbar). + eng: Enables deinterlacing (method selectable in Advanced panel). + fra: Active le désentrelacement (méthode sélectionnable dans le panneau Avancé). + ita: Abilita il deinterlacciamento (metodo selezionabile nel pannello Avanzato). + spa: Habilita el desentrelazado (método seleccionable en el panel Avanzado). + chs: 启用反交错(方法可在高级面板中选择)。 + jpn: デインターレースを有効にします(方法は詳細パネルで選択可能)。 + rus: Включает деинтерлейс (метод выбирается в панели Дополнительно). + por: Habilita o desentrelaçamento (método selecionável no painel Avançado). + swe: Aktiverar deinterlacing (metod valbar i panelen Avancerat). + pol: Włącza usuwanie przeplotu (metoda do wyboru w panelu Zaawansowane). + ukr: Вмикає деінтерлейс (метод обирається в панелі Додатково). + kor: 디인터레이스를 활성화합니다 (방법은 고급 패널에서 선택 가능). + ron: Activează deinterlacing (metoda selectabilă în panoul Avansat). Enables true lossless coding by bypassing scaling, transform, quantization and in-loop filtering.: deu: Ermöglicht echte verlustfreie Kodierung unter Umgehung von Skalierung, Transformation, Quantisierung und In-Loop-Filterung. eng: Enables true lossless coding by bypassing scaling, transform, quantization and in-loop filtering. @@ -2263,6 +2402,21 @@ Fast first pass: ukr: Швидкий перший прохід kor: 빠른 첫 패스 ron: Prima trecere rapidă +Field Order: + eng: Field Order + deu: Feld Bestellung + fra: Ordre de mission + ita: Ordine di campo + spa: Orden de campo + jpn: フィールドオーダー + rus: Полевой заказ + por: Ordem de campo + swe: Fältbeställning + pol: Field Order + chs: 实地订单 + ukr: Польовий порядок + kor: 필드 주문 + ron: Comanda de câmp File: deu: Datei eng: File @@ -2458,6 +2612,21 @@ Generating thumbnail: ukr: Створення мініатюри kor: 썸네일 생성 ron: Generarea miniaturii +General: + eng: General + deu: Allgemein + fra: Général + ita: Generale + spa: General + jpn: 一般 + rus: Общие сведения + por: Geral + swe: Allmänt + pol: Ogólne + chs: 一般情况 + ukr: Генерал + kor: 일반 + ron: Generalități Google's VP9 HDR Encoding Guide: deu: VP9-HDR-Kodierungsanleitung von Google eng: Google's VP9 HDR Encoding Guide @@ -2548,6 +2717,21 @@ HDR10+ Optimizations: ukr: Оптимізація HDR10+ kor: HDR10+ 최적화 ron: Optimizări HDR10+ +HDR tonemapping unavailable for preview - colors in output will differ from this thumbnail: + eng: HDR tonemapping unavailable for preview - colors in output will differ from this thumbnail + deu: HDR-Tonemapping in der Vorschau nicht verfügbar - die Farben in der Ausgabe weichen von dieser Miniaturansicht ab + fra: Le tonemapping HDR n'est pas disponible pour la prévisualisation - les couleurs de la sortie seront différentes de celles de la vignette. + ita: "Il tonemapping HDR non è disponibile per l'anteprima: i colori in uscita differiscono da questa miniatura." + spa: El mapeado tonal HDR no está disponible para la previsualización - los colores en la salida diferirán de esta miniatura + jpn: プレビューではHDRトーンマッピングを使用できません。 + rus: Тонирование HDR недоступно для предварительного просмотра - цвета на выходе будут отличаться от этой миниатюры + por: Mapeamento de tons HDR indisponível para pré-visualização - as cores na saída serão diferentes desta miniatura + swe: HDR tonemapping är inte tillgänglig för förhandsgranskning - färgerna i resultatet kommer att skilja sig från denna miniatyrbild + pol: Mapowanie tonów HDR niedostępne w podglądzie - kolory na wyjściu będą się różnić od tej miniatury. + chs: 预览时无法使用 HDR 色调映射 - 输出中的颜色将与此缩略图不同 + ukr: HDR тональність недоступна для попереднього перегляду - кольори на виході відрізнятимуться від цієї мініатюри + kor: 미리보기에서 HDR 톤 매핑을 사용할 수 없음 - 출력의 색상이 이 썸네일과 다를 수 있습니다. + ron: HDR tonemapping indisponibil pentru previzualizare - culorile din ieșire vor diferi de această miniatură Have to select a video first: deu: Es muss zuerst ein Video ausgewählt werden eng: Have to select a video first @@ -2623,6 +2807,36 @@ Horizontal Flip: ukr: Горизонтальне сальто kor: 수평 뒤집기 ron: Flip orizontal +Has B-Frames: + eng: Has B-Frames + deu: Hat B-Frames + fra: A des cadres B + ita: Ha cornici B + spa: Tiene marcos B + jpn: Bフレーム + rus: Имеет B-образные рамки + por: Tem molduras B + swe: Har B-ramar + pol: Posiada ramki B + chs: 有 B 型框架 + ukr: Має B-фрейми + kor: B-프레임 있음 + ron: Are rame B +Index: + eng: Index + deu: Index + fra: Index + ita: Indice + spa: Índice + jpn: インデックス + rus: Индекс + por: Índice + swe: Index + pol: Indeks + chs: 索引 + ukr: Індекс + kor: 색인 + ron: Index Init Q: deu: Init Q eng: Init Q @@ -2713,6 +2927,21 @@ Invalid Crop: ukr: Неприпустима культура kor: 잘못된 자르기 ron: Cultură invalidă +JSON Files: + eng: JSON Files + deu: JSON-Dateien + fra: Fichiers JSON + ita: File JSON + spa: Archivos JSON + jpn: JSONファイル + rus: Файлы JSON + por: Ficheiros JSON + swe: JSON-filer + pol: Pliki JSON + chs: JSON 文件 + ukr: Файли JSON + kor: JSON 파일 + ron: Fișiere JSON It is recommended that AQ-mode be enabled along with this feature: deu: Es wird empfohlen, dass zusammen mit dieser Funktion auch der AQ-Mode aktiviert wird eng: It is recommended that AQ-mode be enabled along with this feature @@ -3043,6 +3272,21 @@ Min Q: ukr: Min Q kor: 민큐 ron: Min Q +Mode: + deu: Modus + eng: Mode + fra: Mode + ita: Modalità + spa: Modo + chs: 模式 + jpn: モード + rus: Режим + por: Modo + swe: Läge + pol: Tryb + ukr: Режим + kor: 모드 + ron: Mod Motion vector accuracy: deu: Genauigkeit des Bewegungsvektors eng: Motion vector accuracy @@ -3343,6 +3587,21 @@ Not a video file: ukr: Не відеофайл kor: 동영상 파일이 아닙니다. ron: Nu este un fișier video +Number of Frames: + eng: Number of Frames + deu: Anzahl der Frames + fra: Nombre d'images + ita: Numero di fotogrammi + spa: Número de fotogramas + jpn: フレーム数 + rus: Количество кадров + por: Número de fotogramas + swe: Antal ramar + pol: Liczba klatek + chs: 帧数 + ukr: Кількість кадрів + kor: 프레임 수 + ron: Număr de cadre Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed.: deu: Setzen der dynamischen HDR10+-Metadaten nur in die IDR und Frames, bei denen sich die Werte geändert haben. eng: Only put the HDR10+ dynamic metadata in the IDR and frames where the values have changed. @@ -3418,6 +3677,21 @@ Open Log Directory: ukr: Відкрити каталог журналів kor: 로그 디렉터리 열기 ron: Deschideți directorul de jurnal +Other: + eng: Other + deu: Andere + fra: Autres + ita: Altro + spa: Otros + jpn: その他 + rus: Другие + por: Outros + swe: Övriga + pol: Inne + chs: 其他 + ukr: Інше + kor: 기타 + ron: Altele Output: deu: Ausgabe eng: Output @@ -3583,6 +3857,21 @@ Pause Queue: ukr: Пауза Черга kor: 대기열 일시 중지 ron: Pauză Coadă de așteptare +Pixel Format: + eng: Pixel Format + deu: Pixel Format + fra: Format des pixels + ita: Formato pixel + spa: Formato de píxeles + jpn: ピクセル・フォーマット + rus: Пиксельный формат + por: Formato de pixel + swe: Pixelformat + pol: Format pikseli + chs: 像素格式 + ukr: Формат пікселів + kor: 픽셀 형식 + ron: Format pixel Pixel Format (requires at least 10-bit for HDR): deu: Pixelformat (erfordert mindestens 10-Bit für HDR) eng: Pixel Format (requires at least 10-bit for HDR) @@ -3778,6 +4067,21 @@ Profiles: ukr: Профілі kor: 프로필 ron: Profiluri +Property: + eng: Property + deu: Eigentum + fra: Propriété + ita: Proprietà + spa: Propiedad + jpn: プロパティ + rus: Недвижимость + por: Imóveis + swe: Fastighet + pol: Nieruchomość + chs: 物业 + ukr: Власність + kor: 속성 + ron: Proprietate Python: deu: Python eng: Python @@ -4258,6 +4562,51 @@ Same as Source: ukr: Те саме, що й Джерело kor: 소스와 동일 ron: Același ca și Sursa +Sample Aspect Ratio: + eng: Sample Aspect Ratio + deu: Muster Seitenverhältnis + fra: Rapport d'aspect de l'échantillon + ita: Rapporto d'aspetto del campione + spa: Relación de aspecto de la muestra + jpn: サンプル・アスペクト比 + rus: Соотношение сторон образца + por: Amostra de rácio de aspeto + swe: Aspect Ratio för prov + pol: Przykładowy współczynnik proporcji + chs: 样品长宽比 + ukr: Зразок співвідношення сторін + kor: 샘플 종횡비 + ron: Raportul de aspect al eșantionului +Sample Format: + eng: Sample Format + deu: Musterformat + fra: Modèle de format + ita: Formato campione + spa: Modelo de formato + jpn: サンプル・フォーマット + rus: Образец формата + por: Formato de amostra + swe: Exempel på format + pol: Przykładowy format + chs: 格式样本 + ukr: Зразок формату + kor: 샘플 형식 + ron: Exemplu de format +Sample Rate: + eng: Sample Rate + deu: Stichprobenrate + fra: Taux d'échantillonnage + ita: Frequenza di campionamento + spa: Frecuencia de muestreo + jpn: サンプルレート + rus: Частота дискретизации + por: Taxa de amostragem + swe: Samplingsfrekvens + pol: Częstotliwość próbkowania + chs: 采样率 + ukr: Частота дискретизації + kor: 샘플 속도 + ron: Rata de eșantionare Save: deu: speichern eng: Save @@ -4468,6 +4817,21 @@ Settings: ukr: Налаштування kor: 설정 ron: Setări +Side Data: + eng: Side Data + deu: Seite Daten + fra: Données latérales + ita: Dati laterali + spa: Datos laterales + jpn: サイドデータ + rus: Побочные данные + por: Dados laterais + swe: Sidodata + pol: Dane boczne + chs: 侧面数据 + ukr: Побічні ефекти + kor: 사이드 데이터 + ron: Date laterale Single Pass (Bitrate): deu: Ein einzelner Durchlauf (Bitrate) eng: Single Pass (Bitrate) @@ -4783,6 +5147,21 @@ Supported Image Files: ukr: Підтримувані файли зображень kor: 지원되는 이미지 파일 ron: Fișiere de imagine acceptate +Tags: + eng: Tags + deu: Tags + fra: Tags + ita: Tag + spa: Etiquetas + jpn: タグ + rus: Теги + por: Etiquetas + swe: Etiketter + pol: Tagi + chs: 标签 + ukr: Теги + kor: 태그 + ron: Etichete The GUI might have died, but I'm going to keep converting!: deu: Die GUI ist eventuell abgestürzt, aber ich werde weiter konvertieren! eng: The GUI might have died, but I'm going to keep converting! @@ -5308,6 +5687,21 @@ VBR Target: ukr: 'Значення: 0:none; 1:fast; 2:full(trellis) за замовчуванням' kor: '값: 0:없음; 1:빠른; 2:전체(격자) 기본값' ron: 'Valori: 0:niciunul; 1:rapid; 2:complet (spalier) implicit' +Value: + eng: Value + deu: Wert + fra: Valeur + ita: Valore + spa: Valor + jpn: 価値 + rus: Значение + por: Valor + swe: Värde + pol: Wartość + chs: 价值 + ukr: Значення + kor: 가치 + ron: Valoare Variable: deu: Variabel eng: Variable @@ -6058,21 +6452,6 @@ good is the default and recommended for most applications: ukr: 'hdr10: Примусова сигналізація параметрів HDR10 у пакетах SEI.' kor: 'HDR10: SEI 패킷에서 HDR10 파라미터를 강제 신호화합니다.' ron: 'hdr10: Forțează semnalizarea parametrilor HDR10 în pachetele SEI.' -hq - High Quality, ll - Low Latency, ull - Ultra Low Latency: - deu: hq - Hohe Qualität, ll - niedrige Latenz, ull - extrem niedrige Latenz - eng: hq - High Quality, ll - Low Latency, ull - Ultra Low Latency - fra: hq - Haute qualité, ll - Latence faible, ull - Latence ultra faible - ita: hq - Alta qualità, ll - Bassa latenza, ull - Ultra bassa latenza - spa: hq - Alta calidad, ll - Baja latencia, ull - Ultra baja latencia - chs: hq - 高质量,ll - 低延迟,ull - 超低延迟。 - jpn: hq - 高品質, ll - 低遅延, ull - 超低遅延 - rus: hq - высокое качество, ll - низкая задержка, ull - сверхнизкая задержка - por: hq - Alta Qualidade, ll - Baixa Latência, ull - Ultra Baixa Latência - swe: hq - Hög kvalitet, ll - Låg latenstid, ull - Ultralåg latenstid - pol: hq - High Quality, ll - Low Latency, ull - Ultra Low Latency - ukr: hq - Висока якість, ll - Низька затримка, ull - Наднизька затримка - kor: hq - 고품질, ll - 저지연, ull - 초저지연 - ron: hq - Calitate înaltă, ll - Latență redusă, ull - Latență foarte redusă installer: deu: Installationsprogramm eng: installer @@ -7078,6 +7457,21 @@ Drag and Drop to reorder - All items need to be same dimensions: ukr: Перетягніть, щоб змінити порядок - всі елементи повинні мати однакові розміри kor: 드래그 앤 드롭으로 재주문 - 모든 항목의 크기가 동일해야 합니다. ron: Trageți și plasați pentru a reordona - Toate articolele trebuie să aibă aceleași dimensiuni +Drag and Drop to reorder, or drop a folder to load files - All items need to be same dimensions: + deu: Ziehen und Ablegen zum Neuordnen, oder Ordner ablegen um Dateien zu laden - Alle Elemente müssen die gleichen Abmessungen haben + eng: Drag and Drop to reorder, or drop a folder to load files - All items need to be same dimensions + fra: Glissez et déposez pour réorganiser, ou déposez un dossier pour charger les fichiers - Tous les éléments doivent avoir les mêmes dimensions + ita: Trascina e rilascia per riordinare, o rilascia una cartella per caricare i file - Tutti gli elementi devono avere le stesse dimensioni + spa: Arrastrar y soltar para reordenar, o soltar una carpeta para cargar archivos - Todos los elementos deben tener las mismas dimensiones + chs: 拖放来重新排序,或拖放文件夹来加载文件 - 所有项目的尺寸都需要相同 + jpn: ドラッグ&ドロップで並び替え、またはフォルダをドロップしてファイルを読み込み - すべてのアイテムが同じ寸法である必要があります + rus: Перетаскивание для изменения порядка или перетащите папку для загрузки файлов - все элементы должны иметь одинаковые размеры + por: Arraste e solte para reordenar, ou solte uma pasta para carregar arquivos - Todos os itens precisam ter as mesmas dimensões + swe: Dra och släpp för att ändra ordning, eller släpp en mapp för att ladda filer - Alla objekt måste ha samma dimensioner + pol: Przeciągnij i upuść, aby zmienić kolejność, lub upuść folder aby załadować pliki - Wszystkie elementy muszą mieć te same wymiary + ukr: Перетягніть, щоб змінити порядок, або перетягніть папку для завантаження файлів - всі елементи повинні мати однакові розміри + kor: 드래그 앤 드롭으로 재정렬하거나 폴더를 드롭하여 파일 로드 - 모든 항목의 크기가 동일해야 합니다 + ron: Trageți și plasați pentru a reordona, sau plasați un folder pentru a încărca fișierele - Toate articolele trebuie să aibă aceleași dimensiuni The following items were excluded as they could not be identified as image or video files: deu: Die folgenden Elemente wurden ausgeschlossen, da sie nicht als Bild- oder Videodateien identifiziert werden konnten eng: The following items were excluded as they could not be identified as image or video files @@ -10023,6 +10417,96 @@ Codec: ukr: Кодек kor: 코덱 ron: Codec +Codec & Profile: + eng: Codec & Profile + deu: Codec & Profil + fra: Codec et profil + ita: Codec e profilo + spa: Códec y perfil + jpn: コーデック&プロファイル + rus: Кодек и профиль + por: Codec e perfil + swe: Codec & profil + pol: Kodek i profil + chs: 编解码器和简介 + ukr: Кодек і профіль + kor: 코덱 및 프로필 + ron: Codec și profil +Codec Long Name: + eng: Codec Long Name + deu: Codec Langer Name + fra: Nom long du codec + ita: Nome lungo del codec + spa: Nombre largo del códec + jpn: コーデック・ロングネーム + rus: Длинное имя кодека + por: Nome longo do codec + swe: Codec långt namn + pol: Długa nazwa kodeka + chs: 编解码器长名称 + ukr: Повна назва кодека + kor: 코덱 긴 이름 + ron: Codec Nume lung +Codec Name: + eng: Codec Name + deu: Codec-Name + fra: Nom du codec + ita: Nome del codec + spa: Nombre del códec + jpn: コーデック名 + rus: Имя кодека + por: Nome do codec + swe: Codec-namn + pol: Nazwa kodeka + chs: 编解码器名称 + ukr: Назва кодека + kor: 코덱 이름 + ron: Nume codec +Codec Type: + eng: Codec Type + deu: Codec-Typ + fra: Type de codec + ita: Tipo di codec + spa: Tipo de códec + jpn: コーデック・タイプ + rus: Тип кодека + por: Tipo de codec + swe: Codec-typ + pol: Typ kodeka + chs: 编解码器类型 + ukr: Тип кодека + kor: 코덱 유형 + ron: Tip codec +'e.g. dts, truehd, aac': + eng: 'e.g. dts, truehd, aac' + deu: z.B. dts, truehd, aac + fra: par exemple, dts, truehd, aac + ita: ad esempio dts, truehd, aac + spa: por ejemplo, dts, truehd, aac + jpn: 例:DTS、Truehd、AAC + rus: например, dts, truehd, aac + por: por exemplo, dts, truehd, aac + swe: t.ex. dts, truehd, aac + pol: np. dts, truehd, aac + chs: 例如:DTS、TRUEHD、AAC + ukr: наприклад, dts, truehd, aac + kor: '예: DTS, TRUEHD, AAC' + ron: de ex. dts, truehd, aac +'e.g. dts:DTS-HD MA': + eng: 'e.g. dts:DTS-HD MA' + deu: z.B. dts:DTS-HD MA + fra: par exemple dts:DTS-HD MA + ita: ad esempio dts:DTS-HD MA + spa: por ejemplo, dts:DTS-HD MA + jpn: 例:dts:DTS-HD MA + rus: например, dts:DTS-HD MA + por: por exemplo, dts:DTS-HD MA + swe: t.ex. dts:DTS-HD MA + pol: np. dts:DTS-HD MA + chs: 例如 dts:DTS-HD MA + ukr: наприклад, dts:DTS-HD MA + kor: '예: dts:DTS-HD MA' + ron: de ex. dts:DTS-HD MA Near Lossless: eng: Near Lossless deu: Fast verlustfrei @@ -10098,6 +10582,21 @@ Custom Bitrate: ukr: Користувацький бітрейт kor: 사용자 지정 비트레이트 ron: Bitrate personalizat +AAC Profile: + eng: AAC Profile + deu: AAC-Profil + fra: Profil du CAA + ita: Profilo AAC + spa: Perfil de la CAA + jpn: AACプロフィール + rus: Профиль AAC + por: Perfil da AAC + swe: Profil AAC + pol: Profil AAC + chs: AAC 简介 + ukr: Профіль AAC + kor: AAC 프로필 + ron: Profilul AAC Audio Quality: eng: Audio Quality deu: Audio-Qualität @@ -13257,6 +13756,8 @@ Data streams are not supported in this output format: ukr: Потоки даних не підтримуються у цьому форматі виводу kor: 이 출력 형식에서는 데이터 스트림이 지원되지 않습니다. ron: Fluxurile de date nu sunt acceptate în acest format de ieșire +Data and attachment streams are not supported by hardware encoders for this output format: + eng: Data and attachment streams are not supported by hardware encoders for this output format Downloading HDR10+ Tool: eng: Downloading HDR10+ Tool deu: Herunterladen des HDR10+ Tools @@ -14142,6 +14643,21 @@ Show completion popup message: ukr: Показати спливаюче повідомлення про завершення kor: 완료 팝업 메시지 표시 ron: Afișați mesajul pop-up de finalizare +Keep source loaded after adding to queue: + eng: Keep source loaded after adding to queue + deu: Quelle nach Hinzufügen zur Warteschlange geladen lassen + fra: Maintenir la source chargée après l'avoir ajoutée à la file d'attente + ita: Mantenere la sorgente caricata dopo l'aggiunta alla coda + spa: Mantener la fuente cargada después de añadirla a la cola + jpn: キューに追加した後もソースをロードしたままにする + rus: Сохраняйте источник загруженным после добавления в очередь + por: Manter a fonte carregada após adicionar à fila + swe: Håll källan laddad efter att den har lagts till i kön + pol: Zachowaj załadowane źródło po dodaniu do kolejki + chs: 添加到队列后保持源加载 + ukr: Тримати джерело завантаженим після додавання до черги + kor: 대기열에 추가한 후에도 소스가 로드된 상태로 유지 + ron: Mențineți sursa încărcată după adăugarea la coadă Show error popup message: eng: Show error popup message deu: Fehler-Popup-Meldung anzeigen @@ -14896,3 +15412,1323 @@ Please load a video first: ukr: Будь ласка, спочатку завантажте відео kor: 먼저 동영상을 로드하세요. ron: Vă rugăm să încărcați mai întâi un videoclip +Reverse Video: + eng: Reverse Video + deu: Video umkehren + fra: Vidéo inversée + ita: Video inverso + spa: Vídeo inverso + jpn: リバースビデオ + rus: Обратное видео + por: Vídeo invertido + swe: Omvänd video + pol: Odwrócone wideo + chs: 反向视频 + ukr: Зворотне відео + kor: 리버스 비디오 + ron: Reverse Video +Gamma: + eng: Gamma + deu: Gamma + fra: Gamma + ita: Gamma + spa: Gamma + jpn: ガンマ + rus: Гамма + por: Gama + swe: Gamma + pol: Gamma + chs: 伽马 + ukr: Гамма. + kor: 감마 + ron: Gamma +Not supported by rigaya's hardware encoders (Video Speed, Reverse Video): + eng: Not supported by rigaya's hardware encoders (Video Speed, Reverse Video) + deu: Nicht unterstützt von Rigayas Hardware-Encodern (Video Speed, Reverse Video) + fra: Non pris en charge par les encodeurs matériels de rigaya (vitesse vidéo, vidéo inversée) + ita: Non supportato dai codificatori hardware di rigaya (Velocità video, Video inverso) + spa: No compatible con los codificadores hardware de rigaya (Velocidad de vídeo, Vídeo inverso) + jpn: リガヤのハードウェアエンコーダではサポートされていません(ビデオスピード、リバースビデオ) + rus: Не поддерживается аппаратными кодировщиками rigaya (Video Speed, Reverse Video). + por: Não suportado pelos codificadores de hardware da rigaya (velocidade de vídeo, vídeo invertido) + swe: Stöds inte av Rigayas hårdvarukodare (videohastighet, omvänd video) + pol: Nieobsługiwane przez kodery sprzętowe rigaya (Video Speed, Reverse Video). + chs: 不支持 rigaya 硬件编码器(视频速度、反向视频) + ukr: Не підтримується апаратними кодерами rigaya (Video Speed, Reverse Video) + kor: 리가야의 하드웨어 인코더(비디오 속도, 리버스 비디오)에서는 지원되지 않습니다. + ron: Nu este acceptat de codificatoarele hardware ale rigaya (Video Speed, Reverse Video) +Reverse Video Warning: + eng: Reverse Video Warning + deu: Rückwärts-Video-Warnung + fra: Avertissement vidéo inversé + ita: Avviso video di retromarcia + spa: Advertencia de vídeo inverso + jpn: 逆再生ビデオ警告 + rus: Видеопредупреждение о реверсе + por: Aviso de vídeo em marcha-atrás + swe: Varning för omvänd video + pol: Ostrzeżenie o odwróconym wideo + chs: 反向视频警告 + ukr: Відеопопередження про рух заднім ходом + kor: 리버스 비디오 경고 + ron: Avertizare video inversă +The reverse filter buffers all video frames in memory.: + eng: The reverse filter buffers all video frames in memory. + deu: Der Umkehrfilter puffert alle Videobilder im Speicher. + fra: Le filtre inversé met en mémoire tampon toutes les images vidéo. + ita: Il filtro inverso memorizza tutti i fotogrammi video. + spa: El filtro inverso almacena todos los fotogramas de vídeo en la memoria. + jpn: リバースフィルターは、すべてのビデオフレームをメモリにバッファリングする。 + rus: Обратный фильтр буферизирует все видеокадры в памяти. + por: O filtro reverso armazena todos os quadros de vídeo na memória. + swe: Det omvända filtret buffrar alla videobilder i minnet. + pol: Filtr wsteczny buforuje wszystkie klatki wideo w pamięci. + chs: 反向滤波器在内存中缓冲所有视频帧。 + ukr: Зворотний фільтр буферизує всі відеокадри в пам'яті. + kor: 리버스 필터는 메모리의 모든 비디오 프레임을 버퍼링합니다. + ron: Filtrul invers stochează toate cadrele video în memorie. +This may require significant RAM for long or high-resolution videos.: + eng: This may require significant RAM for long or high-resolution videos. + deu: Dies kann bei langen oder hochauflösenden Videos viel Arbeitsspeicher erfordern. + fra: Cela peut nécessiter une quantité importante de mémoire vive pour les vidéos longues ou à haute résolution. + ita: Ciò può richiedere una notevole quantità di RAM per i video lunghi o ad alta risoluzione. + spa: Esto puede requerir una cantidad considerable de RAM para vídeos largos o de alta resolución. + jpn: 長い動画や高解像度の動画では、かなりのRAMが必要になるかもしれない。 + rus: Это может потребовать значительного объема оперативной памяти для длинных видеороликов или видеороликов с высоким разрешением. + por: Isto pode exigir uma quantidade significativa de RAM para vídeos longos ou de alta resolução. + swe: Detta kan kräva mycket RAM-minne för långa eller högupplösta videor. + pol: Może to wymagać znacznej ilości pamięci RAM w przypadku długich filmów lub filmów w wysokiej rozdzielczości. + chs: 对于长视频或高分辨率视频,这可能需要大量内存。 + ukr: Це може вимагати значного обсягу оперативної пам'яті для довгих відео або відео з високою роздільною здатністю. + kor: 긴 동영상이나 고해상도 동영상의 경우 상당한 RAM이 필요할 수 있습니다. + ron: Acest lucru poate necesita memorie RAM semnificativă pentru videoclipuri lungi sau de înaltă rezoluție. +Audio on converted (non-copy) tracks will also be reversed.: + eng: Audio on converted (non-copy) tracks will also be reversed. + deu: Der Ton auf konvertierten (nicht kopierten) Spuren wird ebenfalls umgekehrt. + fra: L'audio des pistes converties (non copiées) sera également inversé. + ita: Anche l'audio delle tracce convertite (non copiate) sarà invertito. + spa: El audio de las pistas convertidas (no copiadas) también se invertirá. + jpn: 変換された(コピーされていない)トラックのオーディオも反転されます。 + rus: Аудио на конвертированных (не копированных) дорожках также будет перевернуто. + por: O áudio das faixas convertidas (não copiadas) também será invertido. + swe: Ljud på konverterade spår (som inte är kopior) kommer också att reverseras. + pol: Dźwięk na przekonwertowanych (niekopiowanych) ścieżkach również zostanie odwrócony. + chs: 已转换(非复制)音轨上的音频也会反转。 + ukr: Звук на конвертованих (не копіях) доріжках також буде реверсовано. + kor: 변환된(복사본이 아닌) 트랙의 오디오도 반전됩니다. + ron: Audio pe pistele convertite (necopiate) va fi, de asemenea, inversat. +Don't show this warning again: + eng: Don't show this warning again + deu: Zeigen Sie diese Warnung nicht mehr an + fra: Ne plus afficher cet avertissement + ita: Non mostrare più questo avviso + spa: No volver a mostrar este aviso + jpn: この警告を二度と表示しないでください + rus: Больше не показывайте это предупреждение + por: Não voltar a mostrar este aviso + swe: Visa inte denna varning igen + pol: Nie pokazuj więcej tego ostrzeżenia + chs: 不要再显示此警告 + ukr: Не показуйте це попередження більше + kor: 이 경고를 다시 표시하지 마세요. + ron: Nu mai afișați acest avertisment +OK: + eng: OK + deu: OK + fra: OK + ita: OK + spa: OK + jpn: OK + rus: OK + por: OK + swe: OK + pol: OK + chs: 好的 + ukr: ГАРАЗД. + kor: 확인 + ron: OK +Hue: + eng: Hue + deu: Farbton + fra: Hue + ita: Tonalità + spa: Hue + jpn: 色相 + rus: Hue + por: Matiz + swe: Hue + pol: Hue + chs: 色调 + ukr: Відтінок + kor: 색조 + ron: Hue +Video Processing: + eng: Video Processing + deu: Videoverarbeitung + fra: Traitement vidéo + ita: Elaborazione video + spa: Tratamiento de vídeo + jpn: ビデオ加工 + rus: Обработка видео + por: Processamento de vídeo + swe: Videobearbetning + pol: Przetwarzanie wideo + chs: 视频处理 + ukr: Обробка відео + kor: 비디오 처리 + ron: Procesare video +Color: + eng: Color + deu: Farbe + fra: Couleur + ita: Colore + spa: Color + jpn: カラー + rus: Цвет + por: Cor + swe: Färg + pol: Kolor + chs: 颜色 + ukr: Колір + kor: 색상 + ron: Culoare +Video Details: + eng: Video Details + deu: Video-Details + fra: Détails de la vidéo + ita: Dettagli video + spa: Detalles del vídeo + jpn: ビデオ詳細 + rus: Подробности видео + por: Detalhes do vídeo + swe: Video Detaljer + pol: Szczegóły wideo + chs: 视频详情 + ukr: Деталі відео + kor: 비디오 세부 정보 + ron: Detalii video +Sharpen: + eng: Sharpen + deu: Schärfen + fra: Aiguiser + ita: Affilare + spa: Afilar + jpn: 研ぐ + rus: Заточка + por: Afiar + swe: Skärpa + pol: Ostrzenie + chs: 锐化 + ukr: Гостріше. + kor: 샤프닝 + ron: Ascuțiți +Vibrance: + eng: Vibrance + deu: Lebendigkeit + fra: Vibrance + ita: Vibrazione + spa: Vibrance + jpn: 活気 + rus: Vibrance + por: Vibração + swe: Vibrance + pol: Wibracja + chs: 活力 + ukr: Вібрація + kor: 활기 + ron: Vibranță +Color Temperature: + eng: Color Temperature + deu: Farbtemperatur + fra: Température de couleur + ita: Temperatura del colore + spa: Temperatura de color + jpn: 色温度 + rus: Цветовая температура + por: Temperatura de cor + swe: Färgtemperatur + pol: Temperatura barwowa + chs: 色温 + ukr: Колірна температура + kor: 색온도 + ron: Temperatura de culoare +Curves Preset: + eng: Curves Preset + deu: Kurven Voreinstellung + fra: Préréglage des courbes + ita: Curve preimpostate + spa: Preajuste de curvas + jpn: カーブ・プリセット + rus: Предварительная настройка кривых + por: Predefinição de curvas + swe: Förinställda kurvor + pol: Wstępne ustawienie krzywych + chs: 曲线预设 + ukr: Попереднє налаштування кривих + kor: 커브 프리셋 + ron: Curbe prestabilite +Colorbalance: + eng: Colorbalance + deu: Farbbalance + fra: Équilibre des couleurs + ita: Colorbalance + spa: Colorbalance + jpn: カラーバランス + rus: Colorbalance + por: Balanço de cores + swe: Färgbalans + pol: Colorbalance + chs: 色彩平衡 + ukr: Баланс кольорів + kor: 컬러 밸런스 + ron: Colorbalance +Unsharp Mask: + eng: Unsharp Mask + deu: Unscharf maskieren + fra: Masque de netteté + ita: Maschera non nitida + spa: Máscara de desenfoque + jpn: アンシャープマスク + rus: Маска нерезкости + por: Máscara de nitidez + swe: Oskärpa Mask + pol: Maska wyostrzająca + chs: 非清晰蒙版 + ukr: Негостра маска + kor: 선명하지 않은 마스크 + ron: Mască Unsharp +Deflicker: + eng: Deflicker + deu: Deflicker + fra: Deflicker + ita: Deflicker + spa: Deflicker + jpn: デフリッカー + rus: Дефликер + por: Deflicker + swe: Deflicker + pol: Deflicker + chs: 除颤器 + ukr: Мерехтіння + kor: 디플리커 + ron: Deflicker +Pad Aspect: + eng: Pad Aspect + deu: Pad-Aspekt + fra: Aspect du tampon + ita: Aspetto del pad + spa: Aspecto de la almohadilla + jpn: パッド・アスペクト + rus: Аспект площадки + por: Aspeto da almofada + swe: Pad Aspect + pol: Pad Aspect + chs: 垫子规格 + ukr: Аспект прокладки + kor: 패드 측면 + ron: Aspect pad +Pad Color: + eng: Pad Color + deu: Pad Farbe + fra: Couleur du tampon + ita: Colore del tampone + spa: Color de la almohadilla + jpn: パッドカラー + rus: Цвет подушечки + por: Cor da almofada + swe: Pad färg + pol: Kolor podkładki + chs: 焊盘颜色 + ukr: Колір подушечки + kor: 패드 색상 + ron: Culoare tampon +Color & Appearance: + eng: Color & Appearance + deu: Farbe und Erscheinungsbild + fra: Couleur et apparence + ita: Colore e aspetto + spa: Color y aspecto + jpn: 色と外観 + rus: Цвет и внешний вид + por: Cor e aspeto + swe: Färg och utseende + pol: Kolor i wygląd + chs: 颜色和外观 + ukr: Колір і зовнішній вигляд + kor: 색상 및 외관 + ron: Culoare și aspect +LUT3D: + eng: LUT3D + deu: LUT3D + fra: LUT3D + ita: LUT3D + spa: LUT3D + jpn: LUT3D + rus: LUT3D + por: LUT3D + swe: LUT3D + pol: LUT3D + chs: LUT3D + ukr: LUT3D + kor: LUT3D + ron: LUT3D +Select LUT File: + eng: Select LUT File + deu: LUT-Datei auswählen + fra: Sélectionner le fichier LUT + ita: Selezionare il file LUT + spa: Seleccionar archivo LUT + jpn: LUTファイルを選択 + rus: Выберите файл LUT + por: Selecionar ficheiro LUT + swe: Välj LUT-fil + pol: Wybierz plik LUT + chs: 选择 LUT 文件 + ukr: Виберіть файл LUT + kor: LUT 파일 선택 + ron: Selectați fișierul LUT +LUT Files: + eng: LUT Files + deu: LUT-Dateien + fra: Fichiers LUT + ita: File LUT + spa: Archivos LUT + jpn: LUTファイル + rus: Файлы LUT + por: Ficheiros LUT + swe: LUT-filer + pol: Pliki LUT + chs: LUT 文件 + ukr: LUT-файли + kor: LUT 파일 + ron: Fișiere LUT +No LUT file selected: + eng: No LUT file selected + deu: Keine LUT-Datei ausgewählt + fra: Aucun fichier LUT sélectionné + ita: Nessun file LUT selezionato + spa: No se ha seleccionado ningún archivo LUT + jpn: LUTファイルが選択されていない + rus: Файл LUT не выбран + por: Nenhum ficheiro LUT selecionado + swe: Ingen LUT-fil vald + pol: Nie wybrano pliku LUT + chs: 未选择 LUT 文件 + ukr: Не вибрано файл LUT + kor: 선택한 LUT 파일 없음 + ron: Nu este selectat niciun fișier LUT +Pad color (e.g. black, white, 0x000000). FFmpeg only — rigaya always uses black.: + eng: Pad color (e.g. black, white, 0x000000). FFmpeg only — rigaya always uses black. + deu: Pad-Farbe (z.B. schwarz, weiß, 0x000000). Nur FFmpeg - Rigaya verwendet immer Schwarz. + fra: Couleur du bloc (par exemple noir, blanc, 0x000000). FFmpeg uniquement - rigaya utilise toujours le noir. + ita: Colore del pad (ad es. nero, bianco, 0x000000). Solo FFmpeg - rigaya usa sempre il nero. + spa: Color del pad (por ejemplo, negro, blanco, 0x000000). Sólo FFmpeg - rigaya siempre utiliza el negro. + jpn: パッドの色(例:黒、白、0x000000)。FFmpegのみ - rigayaは常に黒を使用します。 + rus: Цвет блока (например, черный, белый, 0x000000). Только для FFmpeg - rigaya всегда использует черный цвет. + por: Cor do bloco (por exemplo, preto, branco, 0x000000). Apenas FFmpeg - rigaya usa sempre preto. + swe: Pad-färg (t.ex. svart, vit, 0x000000). Endast FFmpeg - rigaya använder alltid svart. + pol: Kolor podkładki (np. czarny, biały, 0x000000). Tylko FFmpeg - rigaya zawsze używa koloru czarnego. + chs: 垫色(如黑色、白色、0x000000)。仅限 FFmpeg - rigaya 始终使用黑色。 + ukr: Колір пікселів (наприклад, чорний, білий, 0x000000). Тільки FFmpeg - rigaya завжди використовує чорний. + kor: '패드 색상(예: 검정, 흰색, 0x000000). FFmpeg 전용 - 리가야는 항상 검은색을 사용합니다.' + ron: Culoare pad (de exemplu, negru, alb, 0x000000). Numai FFmpeg - rigaya utilizează întotdeauna negru. +? Not supported by rigaya's hardware encoders (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance) +: eng: Not supported by rigaya's hardware encoders (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance) + deu: Nicht unterstützt von Rigayas Hardware-Encodern (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance) + fra: Non pris en charge par les encodeurs matériels de rigaya (vitesse vidéo, vidéo inversée, Deflicker, Vibrance, température des couleurs, balance des couleurs). + ita: Non supportato dai codificatori hardware di rigaya (Velocità video, Inversione video, Deflicker, Vibrazione, Temperatura colore, Bilanciamento colore). + spa: No es compatible con los codificadores hardware de rigaya (Velocidad de vídeo, Vídeo inverso, Deflicker, Vibrance, Temperatura de color, Colorbalance) + jpn: リガヤのハードウェアエンコーダ(ビデオスピード、リバースビデオ、デフリッカー、ビブランス、色温度、カラーバランス)ではサポートされていません。 + rus: Не поддерживаются аппаратными кодировщиками rigaya (Скорость видео, Обратное видео, Дефликер, Вибрация, Цветовая температура, Цветовой баланс). + por: Não suportado pelos codificadores de hardware da rigaya (Velocidade de vídeo, Vídeo invertido, Deflicker, Vibração, Temperatura de cor, Balanço de cores) + swe: Stöds inte av Rigayas hårdvarukodare (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance) + pol: Nieobsługiwane przez kodery sprzętowe rigaya (prędkość wideo, odwrócenie wideo, migotanie, żywość, temperatura kolorów, balans kolorów). + chs: 不支持 rigaya 硬件编码器(视频速度、反向视频、去闪烁、亮度、色温、色差)。 + ukr: Не підтримується апаратними кодерами rigaya (швидкість відео, реверс відео, мерехтіння, вібрація, колірна температура, колірний баланс) + kor: 리가야의 하드웨어 인코더(비디오 속도, 리버스 비디오, 디플리커, 생동감, 색온도, 컬러밸런스)에서는 지원되지 않습니다. + ron: Nu este acceptat de codificatoarele hardware ale rigaya (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance) +Fast Start (MP4/MOV): + eng: Fast Start (MP4/MOV) + deu: Schnellstart (MP4/MOV) + fra: Démarrage rapide (MP4/MOV) + ita: Avvio veloce (MP4/MOV) + spa: Inicio rápido (MP4/MOV) + jpn: ファストスタート(MP4/MOV) + rus: Быстрый старт (MP4/MOV) + por: Início rápido (MP4/MOV) + swe: Snabb start (MP4/MOV) + pol: Szybki start (MP4/MOV) + chs: 快速启动(MP4/MOV) + ukr: Швидкий старт (MP4/MOV) + kor: 빠른 시작(MP4/MOV) + ron: Start rapid (MP4/MOV) +Moves metadata to the beginning of the file for faster streaming start: + eng: Moves metadata to the beginning of the file for faster streaming start + deu: Verschiebt Metadaten an den Anfang der Datei, um das Streaming zu beschleunigen + fra: Déplace les métadonnées au début du fichier pour un démarrage plus rapide de la lecture en continu. + ita: Sposta i metadati all'inizio del file per un avvio più rapido dello streaming. + spa: Desplaza los metadatos al principio del archivo para acelerar el inicio de la transmisión. + jpn: ストリーミング開始を高速化するために、メタデータをファイルの先頭に移動する。 + rus: Перемещает метаданные в начало файла для ускорения начала потоковой передачи. + por: Move os metadados para o início do ficheiro para um início de transmissão mais rápido + swe: Flyttar metadata till början av filen för snabbare streamingstart + pol: Przenosi metadane na początek pliku w celu szybszego rozpoczęcia przesyłania strumieniowego. + chs: 将元数据移至文件开头,以更快地启动流式传输 + ukr: Переміщує метадані на початок файлу для швидшого запуску потокового передавання + kor: 더 빠른 스트리밍 시작을 위해 메타데이터를 파일 앞부분으로 이동합니다. + ron: Mută metadatele la începutul fișierului pentru o pornire mai rapidă a fluxului +GOP Length: + eng: GOP Length + deu: GOP-Länge + fra: Longueur du GOP + ita: Lunghezza GOP + spa: Longitud del GOP + jpn: GOPの長さ + rus: Длина GOP + por: Comprimento GOP + swe: GOP Längd + pol: Długość GOP + chs: GOP 长度 + ukr: Довжина GOP + kor: GOP 길이 + ron: GOP Lungime +GOP length in frames (leave empty for encoder default): + eng: GOP length in frames (leave empty for encoder default) + deu: GOP-Länge in Frames (leer lassen für Encoder-Standard) + fra: Longueur du GOP en images (laisser vide pour la valeur par défaut de l'encodeur) + ita: Lunghezza GOP in fotogrammi (lasciare vuoto per l'encoder predefinito) + spa: Longitud del GOP en fotogramas (dejar vacío para el codificador por defecto) + jpn: GOPの長さ(フレーム単位)(エンコーダのデフォルトは空のまま + rus: Длина GOP в кадрах (оставить пустым для кодера по умолчанию) + por: Comprimento do GOP em fotogramas (deixar vazio para a predefinição do codificador) + swe: GOP-längd i bildrutor (lämna tom för standardkodning) + pol: Długość GOP w klatkach (pozostaw puste dla domyślnego kodera) + chs: 以帧为单位的 GOP 长度(编码器默认留空) + ukr: Довжина GOP у кадрах (залиште порожнім для кодера за замовчуванням) + kor: 프레임 단위의 GOP 길이(인코더 기본값을 위해 비워둠) + ron: Lungimea GOP în cadre (lăsați gol pentru codarea implicită) +Video Speed Warning: + eng: Video Speed Warning + deu: Video-Geschwindigkeitswarnung + fra: Avertissement vidéo sur la vitesse + ita: Video Avviso di velocità + spa: Aviso de velocidad por vídeo + jpn: ビデオ速度警告 + rus: Видеопредупреждение о скорости + por: Vídeo de aviso de velocidade + swe: Video Hastighetsvarning + pol: Wideo ostrzeżenie o prędkości + chs: 视频速度警告 + ukr: Попередження про швидкість відео + kor: 비디오 속도 경고 + ron: Avertizare video privind viteza +"Warning: Audio will not be modified when changing video speed.": + eng: "Warning: Audio will not be modified when changing video speed." + deu: Achtung! Der Ton wird beim Ändern der Videogeschwindigkeit nicht verändert. + fra: "Avertissement : L'audio ne sera pas modifié lors de la modification de la vitesse de la vidéo." + ita: "Attenzione: L'audio non viene modificato quando si cambia la velocità del video." + spa: 'Advertencia: El audio no se modificará al cambiar la velocidad de vídeo.' + jpn: 警告ビデオスピードを変更しても音声は変更されません。 + rus: 'Внимание: Звук не будет изменен при изменении скорости видео.' + por: 'Aviso: O áudio não será modificado quando a velocidade do vídeo for alterada.' + swe: 'Varning för ljud: Ljudet kommer inte att ändras när videohastigheten ändras.' + pol: 'Ostrzeżenie: Dźwięk nie zostanie zmodyfikowany podczas zmiany prędkości wideo.' + chs: 警告:改变视频速度时不会修改音频。 + ukr: 'Попередження: При зміні швидкості відео звук не змінюється.' + kor: '경고: 동영상 속도를 변경할 때 오디오는 수정되지 않습니다.' + ron: 'Avertisment: Sunetul nu va fi modificat la schimbarea vitezei video.' +Browse: + eng: Browse + deu: durchsuchen + fra: Parcourir + ita: Sfogliare + spa: Visite + jpn: ブラウズ + rus: Просмотреть + por: Navegar + swe: Bläddra + pol: Przeglądaj + chs: 浏览 + ukr: Переглянути + kor: 찾아보기 + ron: Răsfoiește +Clear: + eng: Clear + deu: Klar + fra: Clair + ita: Libero + spa: Claro + jpn: クリア + rus: Очистить + por: Limpo + swe: Klart + pol: Wyczyść + chs: 清晰 + ukr: Чисто + kor: 지우기 + ron: Clar +Thumbnail generation failed - preview may not be available: + eng: Thumbnail generation failed - preview may not be available + deu: Thumbnail-Generierung fehlgeschlagen - möglicherweise ist die Vorschau nicht verfügbar + fra: La génération de vignettes a échoué - l'aperçu n'est peut-être pas disponible + ita: Generazione di miniature non riuscita - l'anteprima potrebbe non essere disponibile + spa: Error en la generación de miniaturas - la vista previa puede no estar disponible + jpn: サムネイル生成に失敗 - プレビューが利用できない可能性があります。 + rus: Не удалось создать миниатюру - предварительный просмотр может быть недоступен + por: Falha na geração de miniaturas - a pré-visualização pode não estar disponível + swe: Generering av miniatyrbild misslyckades - förhandsgranskning kanske inte är tillgänglig + pol: Generowanie miniatur nie powiodło się - podgląd może być niedostępny + chs: 缩略图生成失败 - 可能无法预览 + ukr: Не вдалося згенерувати мініатюри - попередній перегляд може бути недоступним + kor: 썸네일 생성 실패 - 미리 보기를 사용할 수 없습니다. + ron: Generarea miniaturii a eșuat - este posibil ca previzualizarea să nu fie disponibilă +Creation Time: + eng: Creation Time + deu: Schöpfungszeit + fra: Le temps de la création + ita: Tempo di creazione + spa: Tiempo de creación + jpn: 創造の時 + rus: Время создания + por: Tempo de criação + swe: Skapelsetid + pol: Czas tworzenia + chs: 创建时间 + ukr: Час створення + kor: 생성 시간 + ron: Timpul creației +Handler Name: + eng: Handler Name + deu: Name des Bearbeiters + fra: Nom du gestionnaire + ita: Nome del gestore + spa: Nombre del manipulador + jpn: ハンドラー名 + rus: Имя обработчика + por: Nome do manipulador + swe: Handläggarens namn + pol: Nazwa administratora + chs: 处理程序名称 + ukr: Ім'я обробника + kor: 핸들러 이름 + ron: Numele manipulatorului +Timecode: + eng: Timecode + deu: Zeitcode + fra: Code temporel + ita: Timecode + spa: Código de tiempo + jpn: タイムコード + rus: Таймкод + por: Código de tempo + swe: Tidkod + pol: Kod czasowy + chs: 时间码 + ukr: Часовий код + kor: 타임코드 + ron: Timecode +Unknown: + eng: Unknown + deu: Unbekannt + fra: Inconnu + ita: Sconosciuto + spa: Desconocido + jpn: 不明 + rus: Неизвестный + por: Desconhecido + swe: Okänd + pol: Nieznany + chs: 未知 + ukr: Невідомо + kor: 알 수 없음 + ron: Necunoscut +Red X: + eng: Red X + deu: Rotes X + fra: Rouge X + ita: Rosso X + spa: Rojo X + jpn: レッドX + rus: Красный X + por: Vermelho X + swe: Röd X + pol: Czerwony X + chs: 红色 X + ukr: Червоний X + kor: 빨간색 X + ron: Roșu X +Red Y: + eng: Red Y + deu: Rot Y + fra: Rouge Y + ita: Rosso Y + spa: Rojo Y + jpn: レッド・Y + rus: Красный Y + por: Vermelho Y + swe: Röd Y + pol: Czerwony Y + chs: 红色 Y + ukr: Червоний Y + kor: 빨간색 Y + ron: Roșu Y +Green X: + eng: Green X + deu: Grün X + fra: Vert X + ita: Verde X + spa: Verde X + jpn: グリーンX + rus: Зеленый X + por: Verde X + swe: Grön X + pol: Green X + chs: 绿色 X + ukr: Зелений X + kor: 녹색 X + ron: Verde X +Green Y: + eng: Green Y + deu: Grün Y + fra: Vert Y + ita: Verde Y + spa: Verde Y + jpn: グリーンY + rus: Зеленый Y + por: Verde Y + swe: Grön Y + pol: Zielony Y + chs: 绿色 Y + ukr: Зелений Y + kor: Green Y + ron: Verde Y +Blue X: + eng: Blue X + deu: Blau X + fra: Bleu X + ita: Blu X + spa: Azul X + jpn: ブルーX + rus: Синий X + por: Azul X + swe: Blå X + pol: Blue X + chs: 蓝 X + ukr: Синій X + kor: 블루 X + ron: Albastru X +Blue Y: + eng: Blue Y + deu: Blau Y + fra: Bleu Y + ita: Blu Y + spa: Azul Y + jpn: ブルー・Y + rus: Синий Y + por: Azul Y + swe: Blå Y + pol: Niebieski Y + chs: 蓝 Y + ukr: Синій Y + kor: Blue Y + ron: Albastru Y +White Point X: + eng: White Point X + deu: Weißer Punkt X + fra: White Point X + ita: Punto bianco X + spa: Punto Blanco X + jpn: ホワイトポイントX + rus: Уайт Пойнт X + por: Ponto Branco X + swe: White Point X + pol: White Point X + chs: 白点 X + ukr: Біла точка X + kor: 화이트 포인트 X + ron: Punct alb X +White Point Y: + eng: White Point Y + deu: Weißer Punkt Y + fra: White Point Y + ita: Punto Bianco Y + spa: Punto Blanco Y + jpn: ホワイトポイントY + rus: Уайт Пойнт Y + por: Ponto Branco Y + swe: White Point Y + pol: White Point Y + chs: 白点 Y + ukr: Біла точка Y + kor: 화이트 포인트 Y + ron: White Point Y +Min Luminance: + eng: Min Luminance + deu: Min Leuchtdichte + fra: Luminance minimale + ita: Luminanza minima + spa: Luminancia mínima + jpn: 最低輝度 + rus: Минимальная яркость + por: Luminância mínima + swe: Min Luminans + pol: Minimalna luminancja + chs: 最低亮度 + ukr: Мінімальна яскравість + kor: 최소 휘도 + ron: Luminozitate minimă +Max Luminance: + eng: Max Luminance + deu: Maximale Leuchtdichte + fra: Luminance maximale + ita: Luminanza massima + spa: Luminancia máxima + jpn: 最大輝度 + rus: Максимальная яркость + por: Luminância máxima + swe: Max luminans + pol: Maksymalna luminancja + chs: 最大亮度 + ukr: Максимальна яскравість + kor: 최대 휘도 + ron: Luminozitate maximă +Max Content: + eng: Max Content + deu: Maximaler Inhalt + fra: Contenu maximal + ita: Contenuto massimo + spa: Contenido máximo + jpn: 最大コンテンツ + rus: Максимальное содержание + por: Conteúdo máximo + swe: Max innehåll + pol: Maksymalna zawartość + chs: 最大内容 + ukr: Максимальний вміст + kor: 최대 콘텐츠 + ron: Conținut maxim +Max Average: + eng: Max Average + deu: Max Durchschnitt + fra: Max Moyenne + ita: Media massima + spa: Max Media + jpn: 最大平均 + rus: Максимальное среднее + por: Média máxima + swe: Max Genomsnitt + pol: Maksymalna średnia + chs: 最大值 平均值 + ukr: Максимальне середнє значення + kor: 최대 평균 + ron: Max Medie +Codec Tag String: + eng: Codec Tag String + deu: Codec Tag String + fra: Chaîne d'étiquettes de codec + ita: Stringa tag codec + spa: Cadena de etiquetas de códecs + jpn: コーデック・タグ文字列 + rus: Строка тега кодека + por: Codec Tag String + swe: Codec Tag String + pol: Ciąg znacznika kodeka + chs: 编解码器标签字符串 + ukr: Рядок тегу кодека + kor: 코덱 태그 문자열 + ron: Codec Tag String +Codec Tag: + eng: Codec Tag + deu: Codec-Tag + fra: Codec Tag + ita: Tag Codec + spa: Etiqueta del códec + jpn: コーデック・タグ + rus: Тег кодека + por: Etiqueta de codec + swe: Codec-tagg + pol: Znacznik kodeka + chs: 编解码器标签 + ukr: Тег кодека + kor: 코덱 태그 + ron: Etichetă Codec +Coded Width: + eng: Coded Width + deu: Codierte Breite + fra: Largeur codée + ita: Larghezza codificata + spa: Anchura codificada + jpn: コード幅 + rus: Кодированная ширина + por: Largura codificada + swe: Kodad bredd + pol: Kodowana szerokość + chs: 编码宽度 + ukr: Кодована ширина + kor: 코드 너비 + ron: Lățime codificată +Coded Height: + eng: Coded Height + deu: Codierte Höhe + fra: Hauteur codée + ita: Altezza codificata + spa: Altura codificada + jpn: コード化された高さ + rus: Кодированная высота + por: Altura codificada + swe: Kodad höjd + pol: Kodowana wysokość + chs: 编码高度 + ukr: Кодована висота + kor: 코딩된 높이 + ron: Înălțime codificată +Id: + eng: Id + deu: Id + fra: Id + ita: Id + spa: Id + jpn: アイド + rus: Id + por: Id + swe: Id + pol: Id + chs: 同上 + ukr: Ідентифікатор + kor: Id + ron: Id +Time Base: + eng: Time Base + deu: Zeitbasis + fra: Base de temps + ita: Base temporale + spa: Base temporal + jpn: タイムベース + rus: База времени + por: Base de tempo + swe: Tidsbas + pol: Baza czasowa + chs: 时基 + ukr: Часовий базис + kor: 시간 기준 + ron: Baza de timp +Start Pts: + eng: Start Pts + deu: Start Pkt. + fra: Départ Pts + ita: Inizio Pts + spa: Inicio Pts + jpn: スタートポイント + rus: Старт Птс + por: Início Pts + swe: Start Pts + pol: Punkty startowe + chs: 起始点数 + ukr: Стартовий бал + kor: 포인트 시작 + ron: Start Pts +Start Time: + eng: Start Time + deu: Startzeit + fra: Heure de début + ita: Ora di inizio + spa: Hora de inicio + jpn: 開始時間 + rus: Время начала + por: Hora de início + swe: Starttid + pol: Godzina rozpoczęcia + chs: 开始时间 + ukr: Час початку + kor: 시작 시간 + ron: Ora de începere +Duration Ts: + eng: Duration Ts + deu: Dauer Ts + fra: Durée Ts + ita: Durata Ts + spa: Duración Ts + jpn: 期間 Ts + rus: Продолжительность Ц + por: Duração Ts + swe: Varaktighet Ts + pol: Czas trwania Ts + chs: 持续时间 Ts + ukr: Тривалість Ts + kor: 지속 시간 Ts + ron: Durata Ts +Extradata Size: + eng: Extradata Size + deu: Extradata Größe + fra: Extradata Taille + ita: Dimensione Extradata + spa: Tamaño Extradata + jpn: エクストラデータ・サイズ + rus: Размер Extradata + por: Tamanho da Extradata + swe: Extradata Storlek + pol: Rozmiar Extradata + chs: Extradata 尺寸 + ukr: Розмір екстраданих + kor: 추가 데이터 크기 + ron: Extradata Dimensiune +Mime Codec String: + eng: Mime Codec String + deu: Mime Codec String + fra: Chaîne du codec Mime + ita: Stringa di codec mime + spa: Cadena de códecs Mime + jpn: Mimeコーデック文字列 + rus: Строка кодека Mime + por: Cadeia de caracteres do codec Mime + swe: Mime Codec-sträng + pol: Ciąg kodeków Mime + chs: MIME 编解码字符串 + ukr: Mime Codec String + kor: 마임 코덱 문자열 + ron: Mime Codec String +Initial Padding: + eng: Initial Padding + deu: Initiale Polsterung + fra: Rembourrage initial + ita: Imbottitura iniziale + spa: Relleno inicial + jpn: 初期パディング + rus: Начальная прокладка + por: Preenchimento inicial + swe: Initial stoppning + pol: Wypełnienie początkowe + chs: 初始填充 + ukr: Початкове заповнення + kor: 이니셜 패딩 + ron: Umplutura inițială +R Frame Rate: + eng: R Frame Rate + deu: R Bildfrequenz + fra: R Taux de rafraîchissement + ita: R Frequenza fotogrammi + spa: R Frecuencia de imagen + jpn: R フレームレート + rus: R Частота кадров + por: R Taxa de quadros + swe: R Bildfrekvens + pol: R Liczba klatek na sekundę + chs: R 帧频 + ukr: R Частота кадрів + kor: R 프레임 레이트 + ron: R Rata cadrelor +Avg Frame Rate: + eng: Avg Frame Rate + deu: Durchschnittliche Bildrate + fra: Taux de rafraîchissement moyen + ita: Frequenza media dei fotogrammi + spa: Frecuencia de imagen media + jpn: 平均フレームレート + rus: Средняя частота кадров + por: Taxa média de quadros + swe: Genomsnittlig bildfrekvens + pol: Średnia liczba klatek na sekundę + chs: 平均帧频 + ukr: Середня частота кадрів + kor: 평균 프레임 속도 + ron: Rata medie a cadrelor +Nb Frames: + eng: Nb Frames + deu: Nb-Rahmen + fra: Cadres Nb + ita: Cornici Nb + spa: Nb Marcos + jpn: Nbフレーム + rus: Nb Рамки + por: Quadros Nb + swe: Nb ramar + pol: Nb Frames + chs: Nb 框架 + ukr: Nb Рамки + kor: Nb 프레임 + ron: Nb Cadre +Codec Full Name: + eng: Codec Full Name + deu: Codec Vollständiger Name + fra: Nom complet du codec + ita: Nome completo del codec + spa: Nombre completo del códec + jpn: コーデック・フルネーム + rus: Полное имя кодека + por: Nome completo do codec + swe: Codec fullständigt namn + pol: Pełna nazwa kodeka + chs: 编解码器全名 + ukr: Повна назва кодека + kor: 코덱 전체 이름 + ron: Codec Nume complet +Frames: + eng: Frames + deu: Rahmen + fra: Cadres + ita: Cornici + spa: Marcos + jpn: フレーム + rus: Рамки + por: Molduras + swe: Ramar + pol: Ramki + chs: 框架 + ukr: Рамки + kor: 프레임 + ron: Rame +Format: + eng: Format + deu: Format + fra: Format + ita: Formato + spa: Formato + jpn: フォーマット + rus: Формат + por: Formato + swe: Format + pol: Format + chs: 格式 + ukr: Формат + kor: 형식 + ron: Format +B-Frames: + eng: B-Frames + deu: B-Rahmen + fra: Cadres B + ita: Cornici B + spa: Marcos B + jpn: Bフレーム + rus: B-Frames + por: Quadros B + swe: B-ramar + pol: Ramki B + chs: B 型框架 + ukr: B-фрейми + kor: B-프레임 + ron: Rame B +Chroma: + eng: Chroma + deu: Chroma + fra: Chroma + ita: Croma + spa: Croma + jpn: クロマ + rus: Chroma + por: Croma + swe: Chroma + pol: Chroma + chs: 色度 + ukr: Chroma + kor: 크로마 + ron: Chroma +Sample AR: + eng: Sample AR + deu: Beispiel AR + fra: Exemple d'AR + ita: Campione AR + spa: Muestra AR + jpn: サンプルAR + rus: Образец AR + por: Amostra de AR + swe: Prov AR + pol: Przykładowy AR + chs: AR 示例 + ukr: Зразок АР + kor: 샘플 AR + ron: Exemplu AR +Display AR: + eng: Display AR + deu: AR anzeigen + fra: Affichage AR + ita: Visualizzazione AR + spa: Mostrar AR + jpn: ARディスプレイ + rus: Дисплей AR + por: Exibir AR + swe: Display AR + pol: Wyświetlacz AR + chs: 显示 AR + ukr: Дисплей AR + kor: 디스플레이 AR + ron: Afișare AR +Range: + eng: Range + deu: Bereich + fra: Gamme + ita: Gamma + spa: Gama + jpn: レンジ + rus: Диапазон + por: Gama + swe: Räckvidd + pol: Zasięg + chs: 范围 + ukr: Діапазон + kor: 범위 + ron: Gama +Space: + eng: Space + deu: Weltraum + fra: L'espace + ita: Spazio + spa: Espacio + jpn: スペース + rus: Космос + por: Espaço + swe: Utrymme + pol: Przestrzeń + chs: 空间 + ukr: Простір + kor: 공간 + ron: Spațiu +Transfer: + eng: Transfer + deu: Übertragung + fra: Transfert + ita: Trasferimento + spa: Transferencia + jpn: 譲渡 + rus: Передача + por: Transferência + swe: Överföring + pol: Transfer + chs: 转让 + ukr: Переказ + kor: 전송 + ron: Transfer +Primaries: + eng: Primaries + deu: Vorwahlen + fra: Primaires + ita: Primarie + spa: Primarias + jpn: 予備選挙 + rus: Праймериз + por: Primárias + swe: Primärval + pol: Premie + chs: 初选 + ukr: Праймеріз + kor: 프라이머리 + ron: Primare +Mastering display metadata: + eng: Mastering display metadata + deu: Metadaten der Anzeige beherrschen + fra: Maîtriser les métadonnées d'affichage + ita: Padroneggiare i metadati di visualizzazione + spa: Dominar los metadatos de visualización + jpn: ディスプレイ・メタデータを使いこなす + rus: Освоение метаданных дисплея + por: Dominar os metadados de visualização + swe: Metadata för visning av mastering + pol: Opanowanie wyświetlania metadanych + chs: 掌握显示元数据 + ukr: Освоєння метаданих дисплея + kor: 디스플레이 메타데이터 마스터하기 + ron: Stăpânirea metadatelor de afișare +Content light level metadata: + eng: Content light level metadata + deu: Metadaten der Inhaltsebene + fra: Métadonnées sur le niveau de luminosité du contenu + ita: Metadati sul livello di luce del contenuto + spa: Metadatos de nivel de iluminación del contenido + jpn: コンテンツ・ライト・レベルのメタデータ + rus: Метаданные уровня освещенности содержимого + por: Metadados de nível de luz do conteúdo + swe: Metadata för innehållets ljusnivå + pol: Metadane poziomu podświetlenia treści + chs: 内容轻量级元数据 + ukr: Метадані про рівень освітленості вмісту + kor: 콘텐츠 조명 수준 메타데이터 + ron: Metadate privind nivelul de luminozitate al conținutului +Layout: + eng: Layout + deu: Layout + fra: Mise en page + ita: Layout + spa: Diseño + jpn: レイアウト + rus: Макет + por: Disposição + swe: Layout + pol: Układ + chs: 布局 + ukr: Макет + kor: 레이아웃 + ron: Layout +Sample Fmt: + eng: Sample Fmt + deu: Muster Fmt + fra: Fmt de l'échantillon + ita: Esempio di Fmt + spa: Muestra Fmt + jpn: サンプル + rus: Образец Fmt + por: Fmt de amostra + swe: Exempel Fmt + pol: Przykładowy format + chs: 样本格式 + ukr: Зразок Fmt + kor: 샘플 Fmt + ron: Exemplu Fmt +Bits/Sample: + eng: Bits/Sample + deu: Bits/Probe + fra: Bits/échantillon + ita: Bit/Campione + spa: Bits/Muestra + jpn: ビット/サンプル + rus: Биты/образец + por: Bits/Amostra + swe: Bits/prov + pol: Bity/próbka + chs: 比特/样本 + ukr: Біти/вибірка + kor: 비트/샘플 + ron: Biți/echipament +Type: + eng: Type + deu: Typ + fra: Type + ita: Tipo + spa: Tipo + jpn: タイプ + rus: Тип + por: Tipo + swe: Typ + pol: Typ + chs: 类型 + ukr: Тип + kor: 유형 + ron: Tip +Bits/Raw Sample: + eng: Bits/Raw Sample + deu: Bits/Rohstichprobe + fra: Bits/Echantillon brut + ita: Bit/Campione grezzo + spa: Bits/Muestra en bruto + jpn: ビット/生サンプル + rus: Биты/сырой образец + por: Bits/Amostra bruta + swe: Bits/rått prov + pol: Bity/Próbka surowa + chs: 比特/原始样本 + ukr: Біти / Сирий зразок + kor: 비트/원시 샘플 + ron: Biți / Probă brută +Audio Service Type: + eng: Audio Service Type + deu: Audio-Dienstart + fra: Type de service audio + ita: Tipo di servizio audio + spa: Tipo de servicio de audio + jpn: オーディオ・サービス・タイプ + rus: Тип аудиосервиса + por: Tipo de serviço de áudio + swe: Typ av ljudtjänst + pol: Typ usługi audio + chs: 音频服务类型 + ukr: Тип аудіопослуги + kor: 오디오 서비스 유형 + ron: Tip serviciu audio diff --git a/fastflix/data/styles/breeze_styles/dark/stylesheet.qss b/fastflix/data/styles/breeze_styles/dark/stylesheet.qss index e75d15fd..7fe79aa6 100644 --- a/fastflix/data/styles/breeze_styles/dark/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/dark/stylesheet.qss @@ -218,49 +218,51 @@ QRadioButton:disabled QRadioButton::indicator { - width: 1em; - height: 1em; -} - -QRadioButton::indicator:unchecked, -QRadioButton::indicator:unchecked:focus -{ - border-image: url(dark:radio_unchecked_disabled.svg); + width: 12px; + height: 12px; + border: 2px solid #76797c; + border-radius: 8px; + background-color: transparent; } QRadioButton::indicator:unchecked:hover, QRadioButton::indicator:unchecked:pressed { - border: none; - outline: none; - border-image: url(dark:radio_unchecked.svg); + border: 2px solid #aaaaaa; } QRadioButton::indicator:checked { - border: none; - outline: none; - border-image: url(dark:radio_checked.svg); + width: 10px; + height: 10px; + border: 3px solid #4a9eed; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:hover, QRadioButton::indicator:checked:focus, QRadioButton::indicator:checked:pressed { - border: none; - outline: none; - border-image: url(dark:radio_checked.svg); + width: 10px; + height: 10px; + border: 3px solid #4a9eed; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:disabled { - outline: none; - border-image: url(dark:radio_checked_disabled.svg); + width: 10px; + height: 10px; + border: 3px solid #76797c; + border-radius: 8px; + background-color: #aaaaaa; } QRadioButton::indicator:unchecked:disabled { - border-image: url(dark:radio_unchecked_disabled.svg); + border: 2px solid #5a5a5a; } QMenuBar diff --git a/fastflix/data/styles/breeze_styles/light/stylesheet.qss b/fastflix/data/styles/breeze_styles/light/stylesheet.qss index 4ff8ac38..6a7b53a9 100644 --- a/fastflix/data/styles/breeze_styles/light/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/light/stylesheet.qss @@ -218,49 +218,51 @@ QRadioButton:disabled QRadioButton::indicator { - width: 1em; - height: 1em; -} - -QRadioButton::indicator:unchecked, -QRadioButton::indicator:unchecked:focus -{ - border-image: url(light:radio_unchecked_disabled.svg); + width: 12px; + height: 12px; + border: 2px solid #bab9b8; + border-radius: 8px; + background-color: transparent; } QRadioButton::indicator:unchecked:hover, QRadioButton::indicator:unchecked:pressed { - border: none; - outline: none; - border-image: url(light:radio_unchecked.svg); + border: 2px solid #7f8c8d; } QRadioButton::indicator:checked { - border: none; - outline: none; - border-image: url(light:radio_checked.svg); + width: 10px; + height: 10px; + border: 3px solid #3daee9; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:hover, QRadioButton::indicator:checked:focus, QRadioButton::indicator:checked:pressed { - border: none; - outline: none; - border-image: url(light:radio_checked.svg); + width: 10px; + height: 10px; + border: 3px solid #3daee9; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:disabled { - outline: none; - border-image: url(light:radio_checked_disabled.svg); + width: 10px; + height: 10px; + border: 3px solid #bab9b8; + border-radius: 8px; + background-color: #d5d5d5; } QRadioButton::indicator:unchecked:disabled { - border-image: url(light:radio_unchecked_disabled.svg); + border: 2px solid #d5d5d5; } QMenuBar diff --git a/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss b/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss index c8d4575a..2f202f71 100644 --- a/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss +++ b/fastflix/data/styles/breeze_styles/onyx/stylesheet.qss @@ -219,49 +219,51 @@ QRadioButton:disabled QRadioButton::indicator { - width: 1em; - height: 1em; -} - -QRadioButton::indicator:unchecked, -QRadioButton::indicator:unchecked:focus -{ - border-image: url(onyx:radio_unchecked_disabled.png); + width: 12px; + height: 12px; + border: 2px solid #76797c; + border-radius: 8px; + background-color: transparent; } QRadioButton::indicator:unchecked:hover, QRadioButton::indicator:unchecked:pressed { - border: none; - outline: none; - border-image: url(onyx:radio_unchecked.png); + border: 2px solid #aaaaaa; } QRadioButton::indicator:checked { - border: none; - outline: none; - border-image: url(onyx:radio_checked.png); + width: 10px; + height: 10px; + border: 3px solid #4a9eed; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:hover, QRadioButton::indicator:checked:focus, QRadioButton::indicator:checked:pressed { - border: none; - outline: none; - border-image: url(onyx:radio_checked.png); + width: 10px; + height: 10px; + border: 3px solid #4a9eed; + border-radius: 8px; + background-color: white; } QRadioButton::indicator:checked:disabled { - outline: none; - border-image: url(onyx:radio_checked_disabled.png); + width: 10px; + height: 10px; + border: 3px solid #76797c; + border-radius: 8px; + background-color: #aaaaaa; } QRadioButton::indicator:unchecked:disabled { - border-image: url(onyx:radio_unchecked_disabled.png); + border: 2px solid #5a5a5a; } QMenuBar @@ -408,7 +410,7 @@ QSlider:focus QLineEdit { - background-color: #1d2023; + background-color: #434c54; padding: 0.23em; border-style: solid; border: 0.05em solid #76797c; @@ -786,6 +788,7 @@ QPushButton:pressed QComboBox { + background-color: #434c54; border: 0.05em solid #76797c; border-radius: 0.09em; padding: 4px 10px; @@ -794,7 +797,7 @@ QComboBox QComboBox:editable { - background-color: #1d2023; + background-color: #434c54; } QPushButton:checked @@ -850,7 +853,7 @@ QTreeView:hover:pressed QComboBox:hover:pressed:editable { - background-color: #1d2023; + background-color: #434c54; } QComboBox QAbstractItemView diff --git a/fastflix/encoders/av1_aom/command_builder.py b/fastflix/encoders/av1_aom/command_builder.py index 2702caec..944aad5b 100644 --- a/fastflix/encoders/av1_aom/command_builder.py +++ b/fastflix/encoders/av1_aom/command_builder.py @@ -43,12 +43,20 @@ def build(fastflix: FastFlix): if settings.aq_mode != "default": beginning.extend(["-aq-mode", settings.aq_mode]) - if settings.aom_params: - beginning.extend(["-aom-params", ":".join(settings.aom_params)]) + aom_params = settings.aom_params.copy() + if settings.lossless: + aom_params.append("lossless=1") + if aom_params: + beginning.extend(["-aom-params", ":".join(aom_params)]) extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.lossless: + # Lossless mode — no rate control needed + command = beginning + extra + ending + return [Command(command=command, name="Single Pass lossless")] + if settings.bitrate: pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" command_1 = ( @@ -67,5 +75,26 @@ def build(fastflix: FastFlix): Command(command=command_2, name="Second Pass bitrate"), ] elif settings.crf: - command_1 = beginning + ["-crf", str(settings.crf)] + extra + ending - return [Command(command=command_1, name="Single Pass CRF")] + if not settings.single_pass: + pass_log_file = fastflix.current_video.work_path / f"pass_log_file_{secrets.token_hex(10)}" + command_1 = ( + beginning + + ["-passlogfile", str(pass_log_file), "-crf", str(settings.crf), "-pass", "1"] + + extra_both + + ["-an"] + + output_fps + + ["-f", "matroska", null] + ) + command_2 = ( + beginning + + ["-passlogfile", str(pass_log_file), "-crf", str(settings.crf), "-pass", "2"] + + extra + + ending + ) + return [ + Command(command=command_1, name="First Pass CRF"), + Command(command=command_2, name="Second Pass CRF"), + ] + else: + command_1 = beginning + ["-crf", str(settings.crf)] + extra + ending + return [Command(command=command_1, name="Single Pass CRF")] diff --git a/fastflix/encoders/av1_aom/settings_panel.py b/fastflix/encoders/av1_aom/settings_panel.py index d607ed25..753822f8 100644 --- a/fastflix/encoders/av1_aom/settings_panel.py +++ b/fastflix/encoders/av1_aom/settings_panel.py @@ -83,10 +83,15 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_modes(), 0, 2, 5, 4) grid.addLayout(self.init_denoise(), 5, 2, 1, 4) grid.addLayout(self.init_aom_params(), 6, 2, 1, 4) - grid.addLayout(self.init_hdr10plus_row(), 7, 2, 1, 4) + checkboxes = QtWidgets.QHBoxLayout() + checkboxes.addLayout(self.init_single_pass()) + checkboxes.addStretch(1) + checkboxes.addLayout(self.init_lossless()) + grid.addLayout(checkboxes, 7, 2, 1, 4) + grid.addLayout(self.init_hdr10plus_row(), 8, 2, 1, 4) self.ffmpeg_level = QtWidgets.QLabel() - grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) + grid.addWidget(self.ffmpeg_level, 9, 2, 1, 4) custom_layout = self._add_custom() guide_label = QtWidgets.QLabel( @@ -310,6 +315,38 @@ def new_source(self): except Exception: pass + def init_single_pass(self): + return self._add_check_box( + label="Single Pass (uncheck for 2-pass CRF)", + widget_name="single_pass", + tooltip="Uncheck for 2-pass encoding which improves quality at the cost of encoding time", + opt="single_pass", + ) + + def init_lossless(self): + layout = self._add_check_box( + label="Lossless", + widget_name="lossless", + tooltip=( + "Enable lossless encoding mode.\n" + "Produces bit-exact output with no quality loss.\n" + "Rate control options are ignored in lossless mode." + ), + opt="lossless", + connect=lambda: (self._toggle_lossless(), self.main.page_update(build_thumbnail=False)), + ) + return layout + + def _toggle_lossless(self): + enabled = not self.widgets.lossless.isChecked() + self.qp_radio.setEnabled(enabled) + self.bitrate_radio.setEnabled(enabled) + self.widgets.crf.setEnabled(enabled) + self.widgets.custom_crf.setEnabled(enabled) + self.widgets.bitrate.setEnabled(enabled) + self.widgets.custom_bitrate.setEnabled(enabled) + self.widgets.single_pass.setEnabled(enabled) + def init_modes(self): return self._add_modes(recommended_bitrates, recommended_crfs, qp_name="crf") @@ -342,9 +379,11 @@ def update_video_encoder_settings(self): max_muxing_queue_size=self.widgets.max_mux.currentText(), pix_fmt=self.widgets.pix_fmt.currentText().split(":")[1].strip(), denoise_noise_level=denoise_noise_level, + single_pass=self.widgets.single_pass.isChecked(), extra=self.ffmpeg_extras, extra_both_passes=self.widgets.extra_both_passes.isChecked(), aom_params=aom_params_text.split(":") if aom_params_text else [], + lossless=self.widgets.lossless.isChecked(), ) encode_type, q_value = self.get_mode_settings() settings.crf = q_value if encode_type == "qp" else None diff --git a/fastflix/encoders/avc_x264/command_builder.py b/fastflix/encoders/avc_x264/command_builder.py index 6f7137b9..a6ef723a 100644 --- a/fastflix/encoders/avc_x264/command_builder.py +++ b/fastflix/encoders/avc_x264/command_builder.py @@ -39,6 +39,11 @@ def build(fastflix: FastFlix): extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.lossless: + # Lossless mode — use QP 0 which is mathematically lossless, no rate control needed + command = beginning + ["-qp", "0", "-preset:v", settings.preset] + extra + ending + return [Command(command=command, name="Single pass lossless", exe="ffmpeg")] + if settings.bitrate: if settings.bitrate_passes == 2: command_1 = ( diff --git a/fastflix/encoders/avc_x264/settings_panel.py b/fastflix/encoders/avc_x264/settings_panel.py index eb3f6f13..762f6a05 100644 --- a/fastflix/encoders/avc_x264/settings_panel.py +++ b/fastflix/encoders/avc_x264/settings_panel.py @@ -85,6 +85,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_psy_rd(), 5, 2, 1, 4) grid.addLayout(self.init_level(), 6, 0, 1, 2) grid.addLayout(self.init_x264_params(), 6, 2, 1, 4) + grid.addLayout(self.init_lossless(), 7, 0, 1, 2) grid.setRowStretch(9, 1) @@ -216,6 +217,31 @@ def init_level(self): opt="level", ) + def init_lossless(self): + layout = self._add_check_box( + label="Lossless", + widget_name="lossless", + tooltip=( + "Enable true lossless coding by bypassing all lossy compression.\n" + "Reconstructed output pictures are bit-exact to the input pictures.\n" + "All rate control options are ignored in lossless mode.\n" + "Slower presets will generally achieve better compression efficiency." + ), + opt="lossless", + connect=lambda: (self._toggle_lossless(), self.main.page_update(build_thumbnail=False)), + ) + return layout + + def _toggle_lossless(self): + enabled = not self.widgets.lossless.isChecked() + self.qp_radio.setEnabled(enabled) + self.bitrate_radio.setEnabled(enabled) + self.widgets.crf.setEnabled(enabled) + self.widgets.custom_crf.setEnabled(enabled) + self.widgets.bitrate.setEnabled(enabled) + self.widgets.custom_bitrate.setEnabled(enabled) + self.widgets.bitrate_passes.setEnabled(enabled) + def init_x264_params(self): layout = QtWidgets.QHBoxLayout() self.labels.x264_params = QtWidgets.QLabel(t("Additional x264 params")) @@ -272,6 +298,7 @@ def update_video_encoder_settings(self): psy_rd=psy_rd_text if psy_rd_text else None, level=self.widgets.level.currentText(), x264_params=x264_params_text.split(":") if x264_params_text else [], + lossless=self.widgets.lossless.isChecked(), ) encode_type, q_value = self.get_mode_settings() settings.crf = q_value if encode_type == "qp" else None diff --git a/fastflix/encoders/common/audio.py b/fastflix/encoders/common/audio.py index 2fe8f039..6f2396ff 100644 --- a/fastflix/encoders/common/audio.py +++ b/fastflix/encoders/common/audio.py @@ -57,7 +57,7 @@ def _split_quality(quality_str: str) -> List[str]: return quality_str.split() -def build_audio(audio_tracks, audio_file_index=0) -> List[str]: +def build_audio(audio_tracks, audio_file_index=0, reverse_video: bool = False) -> List[str]: command_list = [] has_truehd = False has_opus = False @@ -97,7 +97,10 @@ def build_audio(audio_tracks, audio_file_index=0) -> List[str]: if track.downmix and track.downmix != "No Downmix" else [] ) - channel_layout = [f"-filter:{track.outdex}", f"aformat=channel_layouts={cl}"] + audio_filter = ( + f"areverse,aformat=channel_layouts={cl}" if reverse_video else f"aformat=channel_layouts={cl}" + ) + channel_layout = [f"-filter:{track.outdex}", audio_filter] bitrate_parts = [] if track.conversion_codec not in lossless: @@ -119,6 +122,8 @@ def build_audio(audio_tracks, audio_file_index=0) -> List[str]: ) command_list.extend([f"-c:{track.outdex}", track.conversion_codec]) + if track.conversion_profile: + command_list.extend([f"-profile:{track.outdex}", track.conversion_profile]) command_list.extend(bitrate_parts) command_list.extend(downmix) command_list.extend(channel_layout) diff --git a/fastflix/encoders/common/encc_helpers.py b/fastflix/encoders/common/encc_helpers.py index 9262c86f..611084ea 100644 --- a/fastflix/encoders/common/encc_helpers.py +++ b/fastflix/encoders/common/encc_helpers.py @@ -72,7 +72,7 @@ def rigaya_auto_options(fastflix: FastFlix) -> List[str]: ] -def _parse_frame_rate(frame_rate_str: str) -> Optional[float]: +def parse_frame_rate(frame_rate_str: str) -> Optional[float]: """Parse a frame rate string like '24000/1001' or '30' into a float. Returns None if the string is empty or cannot be parsed. @@ -105,7 +105,7 @@ def rigaya_trim_or_seek(video: Video) -> List[str]: return [] if not video.video_settings.fast_seek: - fps = _parse_frame_rate(video.frame_rate) + fps = parse_frame_rate(video.frame_rate) if fps: start_frame = int(start_time * fps) if start_time else 0 if end_time: @@ -196,6 +196,8 @@ def build_audio(audio_tracks: list[AudioTrack], audio_streams) -> List[str]: bitrate_parts = quality_str.split() command_list.extend(downmix) command_list.extend(["--audio-codec", f"{audio_id}?{track.conversion_codec}"]) + if track.conversion_profile: + command_list.extend(["--audio-profile", f"{audio_id}?{track.conversion_profile}"]) command_list.extend(bitrate_parts) command_list.extend(["--audio-metadata", f"{audio_id}?clear"]) @@ -266,9 +268,17 @@ def build_subtitle(subtitle_tracks: list[SubtitleTrack], subtitle_streams, video return result -def build_data(data_tracks: list[DataTrack], data_streams, attachment_streams) -> List[str]: +def build_data(data_tracks: list[DataTrack], data_streams, attachment_streams, output_path=None) -> List[str]: if not data_tracks: return [] + + # Rigaya encoders can only copy data/attachment streams to Matroska containers. + # MP4/MOV and other formats crash because the muxer can't handle data streams. + if output_path: + ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "" + if f".{ext}" not in {".mkv", ".mka"}: + return [] + command_list = [] data_copies = [] attachment_copies = [] @@ -288,3 +298,188 @@ def build_data(data_tracks: list[DataTrack], data_streams, attachment_streams) - if attachment_copies: command_list.extend(["--attachment-copy", ",".join(attachment_copies)]) return command_list + + +# Mapping of FastFlix FFmpeg denoise preset strings to rigaya VPP equivalents. +# nlmeans -> --vpp-nlmeans (direct equivalent: s->sigma/h, p->patch, r->search) +# atadenoise -> --vpp-knn (closest spatial/temporal alternative) +# hqdn3d -> --vpp-pmd (closest spatial denoiser) +# vaguedenoiser -> --vpp-pmd (no wavelet denoiser in rigaya) +RIGAYA_UNSHARP_MAP: dict[str, list[str]] = { + "unsharp=5:5:0.5:5:5:0.0": ["--vpp-unsharp", "radius=3,weight=0.3"], + "unsharp=5:5:1.0:5:5:0.5": ["--vpp-unsharp", "radius=3,weight=0.6"], + "unsharp=7:7:1.5:7:7:1.0": ["--vpp-unsharp", "radius=5,weight=1.0"], +} + +RIGAYA_DENOISE_MAP: dict[str, list[str]] = { + # nlmeans weak/moderate/strong + "nlmeans=s=1.0:p=3:r=9": ["--vpp-nlmeans", "sigma=1.0,h=1.0,patch=3,search=9"], + "nlmeans=s=1.0:p=7:r=15": ["--vpp-nlmeans", "sigma=1.0,h=1.0,patch=7,search=15"], + "nlmeans=s=10.0:p=13:r=25": ["--vpp-nlmeans", "sigma=10.0,h=10.0,patch=13,search=25"], + # nlmeans_opencl -> same rigaya nlmeans (natively GPU-accelerated) + "nlmeans_opencl=s=1.0:p=3:r=9": ["--vpp-nlmeans", "sigma=1.0,h=1.0,patch=3,search=9"], + "nlmeans_opencl=s=1.0:p=7:r=15": ["--vpp-nlmeans", "sigma=1.0,h=1.0,patch=7,search=15"], + "nlmeans_opencl=s=10.0:p=13:r=25": ["--vpp-nlmeans", "sigma=10.0,h=10.0,patch=13,search=25"], + # atadenoise weak/moderate/strong -> knn + "atadenoise=0a=0.01:0b=0.02:1a=0.01:1b=0.02:2a=0.01:2b=0.02:s=9": [ + "--vpp-knn", + "radius=3,strength=0.04,lerp=0.2,th_lerp=0.8", + ], + "atadenoise=0a=0.02:0b=0.04:1a=0.02:1b=0.04:2a=0.02:2b=0.04:s=9": [ + "--vpp-knn", + "radius=3,strength=0.08,lerp=0.2,th_lerp=0.8", + ], + "atadenoise=0a=0.04:0b=0.12:1a=0.04:1b=0.12:2a=0.04:2b=0.12:s=9": [ + "--vpp-knn", + "radius=3,strength=0.16,lerp=0.2,th_lerp=0.8", + ], + # hqdn3d weak/moderate/strong -> pmd + "hqdn3d=luma_spatial=2:chroma_spatial=1.5:luma_tmp=3:chroma_tmp=2.25": [ + "--vpp-pmd", + "apply_count=1,strength=50,threshold=80", + ], + "hqdn3d=luma_spatial=4:chroma_spatial=3:luma_tmp=6:chroma_tmp=4.5": [ + "--vpp-pmd", + "apply_count=2,strength=80,threshold=100", + ], + "hqdn3d=luma_spatial=10:chroma_spatial=7.5:luma_tmp=15:chroma_tmp=11.25": [ + "--vpp-pmd", + "apply_count=2,strength=100,threshold=120", + ], + # vaguedenoiser weak/moderate/strong -> pmd + "vaguedenoiser=threshold=1:method=soft:nsteps=5": ["--vpp-pmd", "apply_count=1,strength=50,threshold=80"], + "vaguedenoiser=threshold=3:method=soft:nsteps=5": ["--vpp-pmd", "apply_count=2,strength=80,threshold=100"], + "vaguedenoiser=threshold=6:method=soft:nsteps=5": ["--vpp-pmd", "apply_count=2,strength=100,threshold=120"], +} + + +def rigaya_extra_options(video: Video) -> List[str]: + """Build extra VPP filter and encoder arguments for rigaya encoders from advanced panel settings.""" + result: List[str] = [] + vs = video.video_settings + + # Equalizer via --vpp-tweak + tweak_parts = [] + try: + if vs.brightness is not None and vs.brightness.strip(): + val = float(vs.brightness) + if val != 0.0: + tweak_parts.append(f"brightness={max(-1.0, min(1.0, val))}") + except ValueError: + logger.warning(f"Invalid brightness value for rigaya: {vs.brightness}") + try: + if vs.contrast is not None and vs.contrast.strip(): + val = float(vs.contrast) + if val != 1.0: + tweak_parts.append(f"contrast={max(-2.0, min(2.0, val))}") + except ValueError: + logger.warning(f"Invalid contrast value for rigaya: {vs.contrast}") + try: + if vs.saturation is not None and vs.saturation.strip(): + val = float(vs.saturation) + if val != 1.0: + tweak_parts.append(f"saturation={max(0.0, min(3.0, val))}") + except ValueError: + logger.warning(f"Invalid saturation value for rigaya: {vs.saturation}") + try: + if vs.gamma is not None and vs.gamma.strip(): + val = float(vs.gamma) + if val != 1.0: + tweak_parts.append(f"gamma={max(0.1, min(10.0, val))}") + except ValueError: + logger.warning(f"Invalid gamma value for rigaya: {vs.gamma}") + try: + if vs.hue is not None and vs.hue.strip(): + val = float(vs.hue) + if val != 0.0: + tweak_parts.append(f"hue={max(-180.0, min(180.0, val))}") + except ValueError: + logger.warning(f"Invalid hue value for rigaya: {vs.hue}") + if tweak_parts: + result.extend(["--vpp-tweak", ",".join(tweak_parts)]) + + # Unsharp mask (preset-based, takes priority over CAS sharpen for --vpp-unsharp) + has_unsharp = False + if vs.unsharp: + rigaya_unsharp = RIGAYA_UNSHARP_MAP.get(vs.unsharp) + if rigaya_unsharp: + result.extend(rigaya_unsharp) + has_unsharp = True + else: + logger.warning(f"No rigaya unsharp mapping for: {vs.unsharp}") + + # Sharpen (CAS) — only apply if unsharp mask is not already set (both use --vpp-unsharp) + if not has_unsharp: + try: + if vs.sharpen is not None and vs.sharpen.strip(): + val = float(vs.sharpen) + if val > 0: + result.extend(["--vpp-unsharp", f"radius=3,weight={max(0.0, min(1.0, val))}"]) + except ValueError: + logger.warning(f"Invalid sharpen value for rigaya: {vs.sharpen}") + + # Denoise + if vs.denoise: + rigaya_denoise = RIGAYA_DENOISE_MAP.get(vs.denoise) + if rigaya_denoise: + result.extend(rigaya_denoise) + else: + logger.warning(f"No rigaya denoise mapping for: {vs.denoise}") + + # Deblock + if vs.deblock: + if vs.deblock == "weak": + result.extend(["--vpp-deblock", "strength=30"]) + elif vs.deblock == "strong": + result.extend(["--vpp-deblock", "strength=60"]) + + # Curves preset + if vs.curves_preset: + result.extend(["--vpp-curves", f"preset={vs.curves_preset}"]) + + # LUT3D + if vs.lut3d_path: + result.extend(["--vpp-colorspace", f"lut3d={vs.lut3d_path},lut3d_interp=tetrahedral"]) + + # Pad / Letterbox + if vs.pad_aspect and vs.pad_aspect != "none": + try: + num, den = vs.pad_aspect.split(":") + target_ratio = int(num) / int(den) + # Start with source dimensions, apply crop + sw = video.width + sh = video.height + if vs.crop: + sw = sw - vs.crop.left - vs.crop.right + sh = sh - vs.crop.top - vs.crop.bottom + # If scale is applied, use scaled dimensions for pad calculation + if video.output_width is not None and video.output_height is not None: + sw = video.output_width + sh = video.output_height + current_ratio = sw / sh if sh else 1 + if current_ratio < target_ratio: + new_w = int(round(sh * target_ratio / 2) * 2) + pad_left = (new_w - sw) // 2 + pad_right = new_w - sw - pad_left + result.extend(["--vpp-pad", f"{pad_left},{0},{pad_right},{0}"]) + elif current_ratio > target_ratio: + new_h = int(round(sw / target_ratio / 2) * 2) + pad_top = (new_h - sh) // 2 + pad_bottom = new_h - sh - pad_top + result.extend(["--vpp-pad", f"{0},{pad_top},{0},{pad_bottom}"]) + except (ValueError, ZeroDivisionError): + logger.warning(f"Invalid pad aspect for rigaya: {vs.pad_aspect}") + + # Output FPS via --vpp-fps (won't conflict with --fps used for source_fps) + if vs.output_fps: + result.extend(["--vpp-fps", f"fps={vs.output_fps}"]) + + # Video track title + if vs.video_track_title: + result.extend(["--video-metadata", f"title={vs.video_track_title}"]) + + # GOP length + if vs.gop_length is not None: + result.extend(["--gop-len", str(vs.gop_length)]) + + return result diff --git a/fastflix/encoders/common/helpers.py b/fastflix/encoders/common/helpers.py index 99b97831..9b466ebb 100644 --- a/fastflix/encoders/common/helpers.py +++ b/fastflix/encoders/common/helpers.py @@ -70,6 +70,7 @@ def generate_ffmpeg_start( remove_hdr: bool = True, start_extra: Union[List[str], str] = "", extra_inputs: Optional[List[str]] = None, + gop_length=None, **_, ) -> List[str]: command = [str(ffmpeg)] @@ -133,6 +134,9 @@ def generate_ffmpeg_start( if video_track_title: command.extend(["-metadata:s:v:0", f"title={video_track_title}"]) + if gop_length is not None: + command.extend(["-g", str(gop_length)]) + return command @@ -160,6 +164,7 @@ def generate_ending( source_has_rotation=False, copy_data=False, data_tracks=None, + faststart=True, **_, ): command = [] @@ -191,24 +196,48 @@ def generate_ending( if cover: command.extend(cover) + mapped_data = False if data_tracks: has_data = False has_attachment = False for track in data_tracks: if not track.enabled: continue + mapped_data = True command.extend(["-map", f"0:{track.index}"]) + # Clear title/handler metadata (same as audio tracks) + if track.title: + command.extend([f"-metadata:s:{track.outdex}", f"title={track.title}"]) + command.extend([f"-metadata:s:{track.outdex}", f"handler={track.title}"]) + else: + command.extend([f"-metadata:s:{track.outdex}", "title="]) + command.extend([f"-metadata:s:{track.outdex}", "handler="]) if track.codec_type == "data": has_data = True elif track.codec_type == "attachment": has_attachment = True + # Restore mimetype/filename stripped by -map_metadata -1 + # (required by the matroska muxer for attachment streams) + if track.mimetype: + command.extend([f"-metadata:s:{track.outdex}", f"mimetype={track.mimetype}"]) + if track.filename: + command.extend([f"-metadata:s:{track.outdex}", f"filename={track.filename}"]) if has_data: command.extend(["-c:d", "copy"]) if has_attachment: command.extend(["-c:t", "copy"]) elif copy_data: + mapped_data = True command.extend(["-map", "0:d", "-c:d", "copy"]) + # Explicitly disable data/attachment streams when none are mapped to prevent + # FFmpeg from auto-including them (causes failures with formats like MKV) + if not mapped_data: + command.append("-dn") + + if faststart and output_video and output_video.suffix.lower() in (".mp4", ".mov", ".m4v"): + command.extend(["-movflags", "+faststart"]) + if output_video and not null_ending: command.append(str(sanitize(output_video))) else: @@ -238,23 +267,30 @@ def generate_filters( start_filters=None, raw_filters=False, deinterlace=False, + deinterlace_filter="yadif", contrast=None, brightness=None, saturation=None, + gamma=None, + hue=None, + sharpen=None, enable_opencl: bool = False, tone_map: str = "hable", video_speed: Union[float, int] = 1, + reverse_video: bool = False, deblock: Union[str, None] = None, deblock_size: int = 4, denoise: Union[str, None] = None, color_transfer: Optional[str] = None, - **_, + color_primaries: Optional[str] = None, + color_space: Optional[str] = None, + **_kw, ): filter_list = [] if start_filters: filter_list.append(start_filters) if deinterlace: - filter_list.append("yadif") + filter_list.append(deinterlace_filter or "yadif") if crop: filter_list.append(f"crop={crop['width']}:{crop['height']}:{crop['left']}:{crop['top']}") if scale: @@ -262,6 +298,16 @@ def generate_filters( filter_list.append(f"scale={scale}:flags={scale_filter},setsar=1:1") elif sar and sar != "1:1" and sar != "1/1": filter_list.append("setsar=1:1") + pad_aspect = _kw.get("pad_aspect") + if pad_aspect and pad_aspect != "none": + pad_color = _kw.get("pad_color", "black") or "black" + num, den = pad_aspect.split(":") + target_ratio = int(num) / int(den) + filter_list.append( + f"pad=w=if(gt(a\\,{target_ratio})\\,iw\\,ceil(ih*{target_ratio}/2)*2)" + f":h=if(gt(a\\,{target_ratio})\\,ceil(iw/{target_ratio}/2)*2\\,ih)" + f":x=(ow-iw)/2:y=(oh-ih)/2:color={pad_color}" + ) if rotate: if rotate == 1: filter_list.append("transpose=1") @@ -275,10 +321,19 @@ def generate_filters( filter_list.append("hflip") if video_speed and video_speed != 1: filter_list.append(f"setpts={video_speed}*PTS") + if reverse_video: + filter_list.append("reverse") if deblock: filter_list.append(f"deblock=filter={deblock}:block={deblock_size}") if denoise: - filter_list.append(denoise) + if denoise.startswith("nlmeans_opencl"): + filter_list.append(f"format=yuv420p,hwupload,{denoise},hwdownload,format=yuv420p") + else: + filter_list.append(denoise) + if _kw.get("deflicker"): + filter_list.append(_kw["deflicker"]) + if _kw.get("unsharp"): + filter_list.append(_kw["unsharp"]) eq_filters = [] if brightness: @@ -287,9 +342,26 @@ def generate_filters( eq_filters.append(f"saturation={saturation}") if contrast: eq_filters.append(f"contrast={contrast}") + if gamma: + eq_filters.append(f"gamma={gamma}") if eq_filters: eq_filters.insert(0, "eq=eval=frame") filter_list.append(":".join(eq_filters)) + if hue: + filter_list.append(f"hue=h={hue}") + if _kw.get("vibrance"): + filter_list.append(f"vibrance=intensity={_kw['vibrance']}") + if _kw.get("colorbalance"): + filter_list.append(_kw["colorbalance"]) + if _kw.get("color_temperature"): + filter_list.append(f"colortemperature=temperature={_kw['color_temperature']}") + if _kw.get("curves_preset"): + filter_list.append(f"curves=preset={_kw['curves_preset']}") + if _kw.get("lut3d_path"): + filter_list.append(f"lut3d=file='{quoted_path(str(_kw['lut3d_path']))}'") + + if sharpen: + filter_list.append(f"cas=strength={sharpen}") if filter_list and vaapi: filter_list.insert(0, "hwdownload") @@ -305,8 +377,10 @@ def generate_filters( filter_list.append("tonemap_vaapi=format=nv12:p=bt709:t=bt709:m=bt709") else: tin = color_transfer if color_transfer else "smpte2084" + pin = color_primaries if color_primaries else "bt2020" + min_val = color_space if color_space else "bt2020nc" filter_list.append( - f"zscale=tin={tin}:t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap={tone_map}:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" + f"zscale=tin={tin}:pin={pin}:min={min_val}:t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap={tone_map}:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" ) filters = ",".join(filter_list) if filter_list else "" @@ -355,7 +429,14 @@ def generate_all( # Detect source rotation for metadata clearing (FFmpeg auto-rotates during re-encoding) source_rotation_degrees = fastflix.current_video.source_rotation - audio_cmd = build_audio(fastflix.current_video.audio_tracks) if audio else [] + audio_cmd = ( + build_audio( + fastflix.current_video.audio_tracks, + reverse_video=fastflix.current_video.video_settings.reverse_video, + ) + if audio + else [] + ) # Assign file_index to external subtitle tracks and collect unique external file paths subtitle_tracks = fastflix.current_video.subtitle_tracks diff --git a/fastflix/encoders/common/setting_panel.py b/fastflix/encoders/common/setting_panel.py index da923669..4926320c 100644 --- a/fastflix/encoders/common/setting_panel.py +++ b/fastflix/encoders/common/setting_panel.py @@ -12,6 +12,7 @@ from fastflix.ui_scale import scaler from fastflix.widgets.background_tasks import ExtractHDR10 from fastflix.resources import group_box_style, get_icon +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") @@ -265,7 +266,7 @@ def _add_text_box( def _add_check_box(self, label, widget_name, opt, connect="default", enabled=True, tooltip=""): layout = QtWidgets.QHBoxLayout() - self.widgets[widget_name] = QtWidgets.QCheckBox(t(label)) + self.widgets[widget_name] = ToggleSwitch(t(label)) self.opts[widget_name] = opt self.widgets[widget_name].setChecked(self.app.fastflix.config.encoder_opt(self.profile_name, opt)) self.widgets[widget_name].setDisabled(not enabled) @@ -291,7 +292,7 @@ def _add_custom(self, title="Custom ffmpeg options", connect="default", disable_ layout.addWidget(self.labels.ffmpeg_options) self.ffmpeg_extras_widget = QtWidgets.QLineEdit() self.ffmpeg_extras_widget.setText(ffmpeg_extra_command) - self.widgets["extra_both_passes"] = QtWidgets.QCheckBox(t("Both Passes")) + self.widgets["extra_both_passes"] = ToggleSwitch(t("Both Passes")) self.opts["extra_both_passes"] = "extra_both_passes" if connect and connect != "default": @@ -398,8 +399,14 @@ def _add_modes( if not disable_bitrate: self.bitrate_radio = QtWidgets.QRadioButton("Bitrate") self.bitrate_radio.setFixedWidth(scaler.scale(67)) + self.bitrate_radio.setStyleSheet( + "QRadioButton::indicator { width: 12px; height: 12px; border-image: none; border: 2px solid #888888; border-radius: 8px; background-color: transparent; }" + " QRadioButton::indicator:checked { border-image: none; width: 10px; height: 10px; border: 3px solid #4a9eed; border-radius: 8px; background-color: white; }" + " QRadioButton::indicator:unchecked:hover { border-image: none; border: 2px solid #aaaaaa; }" + ) self.widgets.mode.addButton(self.bitrate_radio) self.widgets.bitrate = QtWidgets.QComboBox() + self.widgets.bitrate.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) self.widgets.bitrate.addItems(recommended_bitrates) self.widgets.bitrate_passes = QtWidgets.QComboBox() self.widgets.bitrate_passes.addItems(["1", "2"]) @@ -422,7 +429,7 @@ def _add_modes( self.widgets.custom_bitrate.textChanged.connect(lambda: self.main.build_commands()) self.widgets.custom_bitrate.setValidator(self.only_int) bitrate_box_layout.addWidget(self.bitrate_radio) - bitrate_box_layout.addWidget(self.widgets.bitrate, 1) + bitrate_box_layout.addWidget(self.widgets.bitrate) bitrate_box_layout.addStretch(1) if show_bitrate_passes: self.widgets.bitrate_passes.setCurrentIndex( @@ -439,9 +446,15 @@ def _add_modes( self.qp_radio.setChecked(True) self.qp_radio.setFixedWidth(scaler.scale(67)) self.qp_radio.setToolTip(qp_help) + self.qp_radio.setStyleSheet( + "QRadioButton::indicator { width: 12px; height: 12px; border-image: none; border: 2px solid #888888; border-radius: 8px; background-color: transparent; }" + " QRadioButton::indicator:checked { border-image: none; width: 10px; height: 10px; border: 3px solid #4a9eed; border-radius: 8px; background-color: white; }" + " QRadioButton::indicator:unchecked:hover { border-image: none; border: 2px solid #aaaaaa; }" + ) self.widgets.mode.addButton(self.qp_radio) self.widgets[qp_name] = QtWidgets.QComboBox() + self.widgets[qp_name].setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) self.widgets[qp_name].setToolTip(qp_help) self.widgets[qp_name].addItems(recommended_qps) custom_qp = False @@ -474,7 +487,7 @@ def _add_modes( self.widgets.bitrate_passes.currentIndexChanged.connect(lambda: self.mode_update()) self.widgets.bitrate.currentIndexChanged.connect(lambda: self.mode_update()) self.widgets[qp_name].currentIndexChanged.connect(lambda: self.mode_update()) - qp_box_layout.addWidget(self.widgets[qp_name], 1) + qp_box_layout.addWidget(self.widgets[qp_name]) qp_box_layout.addStretch(1) qp_box_layout.addStretch(1) if disable_custom_qp: diff --git a/fastflix/encoders/gif/command_builder.py b/fastflix/encoders/gif/command_builder.py index 7c46a8e6..38898e8b 100644 --- a/fastflix/encoders/gif/command_builder.py +++ b/fastflix/encoders/gif/command_builder.py @@ -34,11 +34,17 @@ def build(fastflix: FastFlix): vertical_flip=video_settings.vertical_flip, horizontal_flip=video_settings.horizontal_flip, video_speed=video_settings.video_speed, + reverse_video=video_settings.reverse_video, deblock=video_settings.deblock, deblock_size=video_settings.deblock_size, brightness=video_settings.brightness, saturation=video_settings.saturation, contrast=video_settings.contrast, + gamma=video_settings.gamma, + hue=video_settings.hue, + sharpen=video_settings.sharpen, + denoise=video_settings.denoise, + deinterlace=video_settings.deinterlace, custom_filters=f"fps={settings.fps}", raw_filters=True, ) @@ -55,11 +61,17 @@ def build(fastflix: FastFlix): vertical_flip=video_settings.vertical_flip, horizontal_flip=video_settings.horizontal_flip, video_speed=video_settings.video_speed, + reverse_video=video_settings.reverse_video, deblock=video_settings.deblock, deblock_size=video_settings.deblock_size, brightness=video_settings.brightness, saturation=video_settings.saturation, contrast=video_settings.contrast, + gamma=video_settings.gamma, + hue=video_settings.hue, + sharpen=video_settings.sharpen, + denoise=video_settings.denoise, + deinterlace=video_settings.deinterlace, custom_filters=f"fps={settings.fps},palettegen{args}", ) diff --git a/fastflix/encoders/gifski/command_builder.py b/fastflix/encoders/gifski/command_builder.py index e1371e6b..eb9aae7d 100644 --- a/fastflix/encoders/gifski/command_builder.py +++ b/fastflix/encoders/gifski/command_builder.py @@ -26,11 +26,17 @@ def build(fastflix: FastFlix): vertical_flip=video_settings.vertical_flip, horizontal_flip=video_settings.horizontal_flip, video_speed=video_settings.video_speed, + reverse_video=video_settings.reverse_video, deblock=video_settings.deblock, deblock_size=video_settings.deblock_size, brightness=video_settings.brightness, saturation=video_settings.saturation, contrast=video_settings.contrast, + gamma=video_settings.gamma, + hue=video_settings.hue, + sharpen=video_settings.sharpen, + denoise=video_settings.denoise, + deinterlace=video_settings.deinterlace, remove_hdr=video_settings.remove_hdr, tone_map=video_settings.tone_map, custom_filters=f"fps={settings.fps},format=yuv420p", diff --git a/fastflix/encoders/h264_videotoolbox/command_builder.py b/fastflix/encoders/h264_videotoolbox/command_builder.py index 9e8ebad3..7667a5c0 100644 --- a/fastflix/encoders/h264_videotoolbox/command_builder.py +++ b/fastflix/encoders/h264_videotoolbox/command_builder.py @@ -3,33 +3,39 @@ import shlex from fastflix.encoders.common.helpers import Command, generate_all, generate_color_details, null -from fastflix.models.encode import HEVCVideoToolboxSettings +from fastflix.models.encode import H264VideoToolboxSettings from fastflix.models.fastflix import FastFlix def build(fastflix: FastFlix): - settings: HEVCVideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings + settings: H264VideoToolboxSettings = fastflix.current_video.video_settings.video_encoder_settings beginning, ending, output_fps = generate_all(fastflix, "h264_videotoolbox") beginning.extend(generate_color_details(fastflix)) + h264_profiles = {1: "baseline", 2: "main", 3: "high", 4: "extended"} + def clean_bool(item): return "true" if item else "false" - details = [ - "-profile:v", - str(settings.profile), - "-allow_sw", - clean_bool(settings.allow_sw), - "-require_sw", - clean_bool(settings.require_sw), - "-realtime", - clean_bool(settings.realtime), - "-frames_before", - clean_bool(settings.frames_before), - "-frames_after", - clean_bool(settings.frames_after), - ] + details = [] + profile_name = h264_profiles.get(settings.profile) + if profile_name: + details.extend(["-profile:v", profile_name]) + details.extend( + [ + "-allow_sw", + clean_bool(settings.allow_sw), + "-require_sw", + clean_bool(settings.require_sw), + "-realtime", + clean_bool(settings.realtime), + "-frames_before", + clean_bool(settings.frames_before), + "-frames_after", + clean_bool(settings.frames_after), + ] + ) extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] diff --git a/fastflix/encoders/hevc_videotoolbox/command_builder.py b/fastflix/encoders/hevc_videotoolbox/command_builder.py index 6746fb63..b87cf8cc 100644 --- a/fastflix/encoders/hevc_videotoolbox/command_builder.py +++ b/fastflix/encoders/hevc_videotoolbox/command_builder.py @@ -13,23 +13,29 @@ def build(fastflix: FastFlix): beginning.extend(generate_color_details(fastflix)) + hevc_profiles = {1: "main", 2: "main10"} + def clean_bool(item): return "true" if item else "false" - details = [ - "-profile:v", - str(settings.profile), - "-allow_sw", - clean_bool(settings.allow_sw), - "-require_sw", - clean_bool(settings.require_sw), - "-realtime", - clean_bool(settings.realtime), - "-frames_before", - clean_bool(settings.frames_before), - "-frames_after", - clean_bool(settings.frames_after), - ] + details = [] + profile_name = hevc_profiles.get(settings.profile) + if profile_name: + details.extend(["-profile:v", profile_name]) + details.extend( + [ + "-allow_sw", + clean_bool(settings.allow_sw), + "-require_sw", + clean_bool(settings.require_sw), + "-realtime", + clean_bool(settings.realtime), + "-frames_before", + clean_bool(settings.frames_before), + "-frames_after", + clean_bool(settings.frames_after), + ] + ) extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] diff --git a/fastflix/encoders/hevc_x265/command_builder.py b/fastflix/encoders/hevc_x265/command_builder.py index 8f2e916f..bf3231fd 100644 --- a/fastflix/encoders/hevc_x265/command_builder.py +++ b/fastflix/encoders/hevc_x265/command_builder.py @@ -178,6 +178,11 @@ def get_x265_params(params=()): extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.lossless: + # Lossless mode — no rate control needed, x265 handles it via lossless=1 param + command = beginning + get_x265_params() + ["-preset:v", settings.preset] + extra + ending + return [Command(command=command, name="Single pass lossless", exe="ffmpeg")] + if settings.bitrate: if settings.bitrate_passes == 2: command_1 = ( diff --git a/fastflix/encoders/hevc_x265/settings_panel.py b/fastflix/encoders/hevc_x265/settings_panel.py index cfbc682f..e7637e0a 100644 --- a/fastflix/encoders/hevc_x265/settings_panel.py +++ b/fastflix/encoders/hevc_x265/settings_panel.py @@ -383,7 +383,7 @@ def init_intra_refresh(self): ) def init_lossless(self): - return self._add_check_box( + layout = self._add_check_box( label="Lossless", widget_name="lossless", tooltip=( @@ -394,7 +394,19 @@ def init_lossless(self): "Slower presets will generally achieve better compression efficiency (and generate smaller bitstreams)." ), opt="lossless", + connect=lambda: (self._toggle_lossless(), self.main.page_update(build_thumbnail=False)), ) + return layout + + def _toggle_lossless(self): + enabled = not self.widgets.lossless.isChecked() + self.qp_radio.setEnabled(enabled) + self.bitrate_radio.setEnabled(enabled) + self.widgets.crf.setEnabled(enabled) + self.widgets.custom_crf.setEnabled(enabled) + self.widgets.bitrate.setEnabled(enabled) + self.widgets.custom_bitrate.setEnabled(enabled) + self.widgets.bitrate_passes.setEnabled(enabled) def init_preset(self): layout = self._add_combo_box( diff --git a/fastflix/encoders/nvencc_av1/command_builder.py b/fastflix/encoders/nvencc_av1/command_builder.py index 53959cec..5000c851 100644 --- a/fastflix/encoders/nvencc_av1/command_builder.py +++ b/fastflix/encoders/nvencc_av1/command_builder.py @@ -13,6 +13,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -73,11 +74,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -87,7 +88,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -168,6 +169,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "split": command.extend(["--split-enc", "auto_forced"]) elif settings.split_mode == "parallel": @@ -179,7 +182,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/nvencc_avc/command_builder.py b/fastflix/encoders/nvencc_avc/command_builder.py index 39ab7286..ac1ea538 100644 --- a/fastflix/encoders/nvencc_avc/command_builder.py +++ b/fastflix/encoders/nvencc_avc/command_builder.py @@ -13,6 +13,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -67,11 +68,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -79,7 +80,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -140,6 +141,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) @@ -149,7 +152,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/nvencc_hevc/command_builder.py b/fastflix/encoders/nvencc_hevc/command_builder.py index e4621aed..f0c8dab0 100644 --- a/fastflix/encoders/nvencc_hevc/command_builder.py +++ b/fastflix/encoders/nvencc_hevc/command_builder.py @@ -13,6 +13,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -73,11 +74,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -87,7 +88,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -168,6 +169,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "split": command.extend(["--split-enc", "auto_forced"]) elif settings.split_mode == "parallel": @@ -179,7 +182,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/qsvencc_av1/command_builder.py b/fastflix/encoders/qsvencc_av1/command_builder.py index 37d40fd0..512e860d 100644 --- a/fastflix/encoders/qsvencc_av1/command_builder.py +++ b/fastflix/encoders/qsvencc_av1/command_builder.py @@ -14,6 +14,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -72,12 +73,11 @@ def build(fastflix: FastFlix): flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) - if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -87,7 +87,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -118,6 +118,9 @@ def build(fastflix: FastFlix): command.extend(["--quality", settings.preset]) + if settings.tune: + command.extend(["--tune", settings.tune]) + if settings.lookahead: command.extend(["--la-depth", str(settings.lookahead)]) @@ -163,6 +166,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) @@ -181,7 +186,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/qsvencc_av1/settings_panel.py b/fastflix/encoders/qsvencc_av1/settings_panel.py index 5795820e..1efa895d 100644 --- a/fastflix/encoders/qsvencc_av1/settings_panel.py +++ b/fastflix/encoders/qsvencc_av1/settings_panel.py @@ -85,8 +85,9 @@ def __init__(self, parent, main, app: FastFlixApp): custom_layout = self._add_custom(title="Custom QSVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) - grid.addLayout(self.init_lookahead(), 1, 0, 1, 2) - grid.addLayout(self.init_qp_mode(), 2, 0, 1, 2) + grid.addLayout(self.init_tune(), 1, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 2, 0, 1, 2) + grid.addLayout(self.init_qp_mode(), 3, 0, 1, 2) grid.addLayout(self.init_adapt_ref(), 5, 0, 1, 2) grid.addLayout(self.init_adapt_ltr(), 6, 0, 1, 2) grid.addLayout(self.init_adapt_cqm(), 7, 0, 1, 2) @@ -171,7 +172,7 @@ def init_tune(self): label="Tune", widget_name="tune", tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", - options=["hq", "ll", "ull", "lossless"], + options=["none", "hq", "ll", "ull", "lossless"], opt="tune", ) @@ -339,6 +340,7 @@ def update_video_encoder_settings(self): adapt_cqm=self.widgets.adapt_cqm.isChecked(), adapt_ref=self.widgets.adapt_ref.isChecked(), split_mode=self.widgets.split_mode.currentText(), + tune=self.widgets.tune.currentText() if self.widgets.tune.currentIndex() != 0 else None, ) encode_type, q_value = self.get_mode_settings() diff --git a/fastflix/encoders/qsvencc_avc/command_builder.py b/fastflix/encoders/qsvencc_avc/command_builder.py index e3d8a23f..ae06dae5 100644 --- a/fastflix/encoders/qsvencc_avc/command_builder.py +++ b/fastflix/encoders/qsvencc_avc/command_builder.py @@ -14,6 +14,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -72,12 +73,11 @@ def build(fastflix: FastFlix): flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) - if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -85,7 +85,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -117,6 +117,9 @@ def build(fastflix: FastFlix): command.extend(["--quality", settings.preset]) command.extend(["--profile", settings.profile]) + if settings.tune: + command.extend(["--tune", settings.tune]) + if settings.lookahead: command.extend(["--la-depth", str(settings.lookahead)]) @@ -142,6 +145,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) @@ -160,7 +165,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/qsvencc_avc/settings_panel.py b/fastflix/encoders/qsvencc_avc/settings_panel.py index 9763fa26..704b5211 100644 --- a/fastflix/encoders/qsvencc_avc/settings_panel.py +++ b/fastflix/encoders/qsvencc_avc/settings_panel.py @@ -84,11 +84,12 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_preset(), 0, 0, 1, 2) grid.addLayout(self.init_profile(), 1, 0, 1, 2) - grid.addLayout(self.init_lookahead(), 2, 0, 1, 2) - grid.addLayout(self.init_qp_mode(), 3, 0, 1, 2) - grid.addLayout(self.init_adapt_ref(), 5, 0, 1, 2) - grid.addLayout(self.init_adapt_ltr(), 6, 0, 1, 2) - grid.addLayout(self.init_adapt_cqm(), 7, 0, 1, 2) + grid.addLayout(self.init_tune(), 2, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 3, 0, 1, 2) + grid.addLayout(self.init_qp_mode(), 4, 0, 1, 2) + grid.addLayout(self.init_adapt_ref(), 6, 0, 1, 2) + grid.addLayout(self.init_adapt_ltr(), 7, 0, 1, 2) + grid.addLayout(self.init_adapt_cqm(), 8, 0, 1, 2) breaker = QtWidgets.QHBoxLayout() breaker_label = QtWidgets.QLabel(t("Advanced")) @@ -98,7 +99,7 @@ def __init__(self, parent, main, app: FastFlixApp): breaker.addWidget(breaker_label, alignment=QtCore.Qt.AlignHCenter) breaker.addWidget(get_breaker(), stretch=1) - grid.addLayout(breaker, 4, 0, 1, 6) + grid.addLayout(breaker, 5, 0, 1, 6) qp_line = QtWidgets.QHBoxLayout() qp_line.addLayout(self.init_decoder()) @@ -107,7 +108,7 @@ def __init__(self, parent, main, app: FastFlixApp): qp_line.addStretch(1) qp_line.addLayout(self.init_max_q()) - grid.addLayout(qp_line, 5, 2, 1, 4) + grid.addLayout(qp_line, 6, 2, 1, 4) advanced = QtWidgets.QHBoxLayout() advanced.addLayout(self.init_10_bit()) @@ -119,13 +120,13 @@ def __init__(self, parent, main, app: FastFlixApp): advanced.addLayout(self.init_level()) advanced.addStretch(1) advanced.addLayout(self.init_metrics()) - grid.addLayout(advanced, 6, 2, 1, 4) + grid.addLayout(advanced, 7, 2, 1, 4) self.ffmpeg_level = QtWidgets.QLabel() - grid.addWidget(self.ffmpeg_level, 8, 2, 1, 4) - grid.addLayout(self.init_parallel_mode(), 7, 4, 1, 2) + grid.addWidget(self.ffmpeg_level, 9, 2, 1, 4) + grid.addLayout(self.init_parallel_mode(), 8, 4, 1, 2) - grid.setRowStretch(9, 1) + grid.setRowStretch(10, 1) guide_label = QtWidgets.QLabel( link( @@ -136,7 +137,7 @@ def __init__(self, parent, main, app: FastFlixApp): ) guide_label.setOpenExternalLinks(True) custom_layout.addWidget(guide_label) - grid.addLayout(custom_layout, 10, 0, 1, 6) + grid.addLayout(custom_layout, 11, 0, 1, 6) self.setLayout(grid) self.hide() @@ -156,7 +157,7 @@ def init_tune(self): label="Tune", widget_name="tune", tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", - options=["hq", "ll", "ull", "lossless"], + options=["none", "hq", "ll", "ull", "lossless"], opt="tune", ) @@ -328,6 +329,7 @@ def update_video_encoder_settings(self): adapt_cqm=self.widgets.adapt_cqm.isChecked(), adapt_ref=self.widgets.adapt_ref.isChecked(), split_mode=self.widgets.split_mode.currentText(), + tune=self.widgets.tune.currentText() if self.widgets.tune.currentIndex() != 0 else None, ) encode_type, q_value = self.get_mode_settings() diff --git a/fastflix/encoders/qsvencc_hevc/command_builder.py b/fastflix/encoders/qsvencc_hevc/command_builder.py index 442868a4..01b6ffa8 100644 --- a/fastflix/encoders/qsvencc_hevc/command_builder.py +++ b/fastflix/encoders/qsvencc_hevc/command_builder.py @@ -14,6 +14,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, ) logger = logging.getLogger("fastflix") @@ -72,12 +73,11 @@ def build(fastflix: FastFlix): flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) - if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -87,7 +87,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -118,6 +118,9 @@ def build(fastflix: FastFlix): command.extend(["--quality", settings.preset]) + if settings.tune: + command.extend(["--tune", settings.tune]) + if settings.lookahead: command.extend(["--la-depth", str(settings.lookahead)]) @@ -163,6 +166,8 @@ def build(fastflix: FastFlix): ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) @@ -181,7 +186,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/qsvencc_hevc/settings_panel.py b/fastflix/encoders/qsvencc_hevc/settings_panel.py index 423b7ef6..a0b7fef0 100644 --- a/fastflix/encoders/qsvencc_hevc/settings_panel.py +++ b/fastflix/encoders/qsvencc_hevc/settings_panel.py @@ -85,8 +85,9 @@ def __init__(self, parent, main, app: FastFlixApp): custom_layout = self._add_custom(title="Custom QSVEncC options", disable_both_passes=True) grid.addLayout(self.init_preset(), 0, 0, 1, 2) + grid.addLayout(self.init_tune(), 1, 0, 1, 2) grid.addLayout(self.init_qp_mode(), 2, 0, 1, 2) - grid.addLayout(self.init_lookahead(), 1, 0, 1, 2) + grid.addLayout(self.init_lookahead(), 3, 0, 1, 2) grid.addLayout(self.init_adapt_ref(), 5, 0, 1, 2) grid.addLayout(self.init_adapt_ltr(), 6, 0, 1, 2) grid.addLayout(self.init_adapt_cqm(), 7, 0, 1, 2) @@ -164,7 +165,7 @@ def init_tune(self): label="Tune", widget_name="tune", tooltip="Tune the settings for a particular type of source or situation\nhq - High Quality, ll - Low Latency, ull - Ultra Low Latency", - options=["hq", "ll", "ull", "lossless"], + options=["none", "hq", "ll", "ull", "lossless"], opt="tune", ) @@ -332,6 +333,7 @@ def update_video_encoder_settings(self): adapt_cqm=self.widgets.adapt_cqm.isChecked(), adapt_ref=self.widgets.adapt_ref.isChecked(), split_mode=self.widgets.split_mode.currentText(), + tune=self.widgets.tune.currentText() if self.widgets.tune.currentIndex() != 0 else None, ) encode_type, q_value = self.get_mode_settings() diff --git a/fastflix/encoders/svt_av1/command_builder.py b/fastflix/encoders/svt_av1/command_builder.py index 572c5718..b21fa9aa 100644 --- a/fastflix/encoders/svt_av1/command_builder.py +++ b/fastflix/encoders/svt_av1/command_builder.py @@ -91,6 +91,9 @@ def convert_me(two_numbers, conversion_rate=50_000) -> str: if enable_hdr: svtav1_params.append("enable-hdr=1") + if settings.lossless: + svtav1_params.append("lossless=1") + if svtav1_params: beginning.extend(["-svtav1-params", ":".join(svtav1_params)]) @@ -103,7 +106,13 @@ def convert_me(two_numbers, conversion_rate=50_000) -> str: extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] - if settings.single_pass: + if settings.lossless: + # Lossless mode — no rate control needed + command = beginning + extra + ending + return [Command(command=command, name="Single pass lossless", exe="ffmpeg")] + + # SVT-AV1 does not support CRF with multi-pass + if settings.single_pass or (not settings.bitrate and settings.qp_mode == "crf"): if settings.bitrate: command_1 = beginning + ["-b:v", settings.bitrate] + extra + ending diff --git a/fastflix/encoders/svt_av1/settings_panel.py b/fastflix/encoders/svt_av1/settings_panel.py index 8012fada..8f4bf658 100644 --- a/fastflix/encoders/svt_av1/settings_panel.py +++ b/fastflix/encoders/svt_av1/settings_panel.py @@ -96,6 +96,7 @@ def __init__(self, parent, main, app: FastFlixApp): grid.addLayout(self.init_film_grain(), 5, 2, 1, 4) grid.addLayout(self.init_film_grain_denoise(), 6, 2, 1, 4) grid.addLayout(self.init_svtav1_params(), 7, 2, 1, 4) + grid.addLayout(self.init_lossless(), 8, 2, 1, 4) grid.setRowStretch(12, 1) custom_layout = self._add_custom() @@ -234,6 +235,30 @@ def init_film_grain_denoise(self): opt="film_grain_denoise", ) + def init_lossless(self): + layout = self._add_check_box( + label="Lossless", + widget_name="lossless", + tooltip=( + "Enable lossless encoding mode (requires SVT-AV1 v2.0+).\n" + "Produces bit-exact output with no quality loss.\n" + "Rate control options are ignored in lossless mode." + ), + opt="lossless", + connect=lambda: (self._toggle_lossless(), self.main.page_update(build_thumbnail=False)), + ) + return layout + + def _toggle_lossless(self): + enabled = not self.widgets.lossless.isChecked() + self.qp_radio.setEnabled(enabled) + self.bitrate_radio.setEnabled(enabled) + self.widgets.qp.setEnabled(enabled) + self.widgets.custom_qp.setEnabled(enabled) + self.widgets.bitrate.setEnabled(enabled) + self.widgets.custom_bitrate.setEnabled(enabled) + self.widgets.qp_mode.setEnabled(enabled) + def init_qp_or_crf(self): return self._add_combo_box( label="Quantization Mode", @@ -327,6 +352,7 @@ def update_video_encoder_settings(self): extra=self.ffmpeg_extras, extra_both_passes=self.widgets.extra_both_passes.isChecked(), svtav1_params=svtav1_params_text.split(":") if svtav1_params_text else [], + lossless=self.widgets.lossless.isChecked(), ) encode_type, q_value = self.get_mode_settings() settings.qp = q_value if encode_type == "qp" else None diff --git a/fastflix/encoders/vceencc_av1/command_builder.py b/fastflix/encoders/vceencc_av1/command_builder.py index 83657803..3395e713 100644 --- a/fastflix/encoders/vceencc_av1/command_builder.py +++ b/fastflix/encoders/vceencc_av1/command_builder.py @@ -13,6 +13,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, pa_builder, ) @@ -63,11 +64,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -77,7 +78,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -146,6 +147,9 @@ def build(fastflix: FastFlix): else "mobius" ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) if settings.metrics: @@ -154,7 +158,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/vceencc_avc/command_builder.py b/fastflix/encoders/vceencc_avc/command_builder.py index 933f2d88..598058f0 100644 --- a/fastflix/encoders/vceencc_avc/command_builder.py +++ b/fastflix/encoders/vceencc_avc/command_builder.py @@ -13,6 +13,7 @@ rigaya_auto_options, rigaya_avformat_reader, rigaya_trim_or_seek, + rigaya_extra_options, pa_builder, ) @@ -63,11 +64,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -75,7 +76,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -132,6 +133,9 @@ def build(fastflix: FastFlix): else "mobius" ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) if settings.metrics: @@ -140,7 +144,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/vceencc_hevc/command_builder.py b/fastflix/encoders/vceencc_hevc/command_builder.py index 496cc54a..261bced7 100644 --- a/fastflix/encoders/vceencc_hevc/command_builder.py +++ b/fastflix/encoders/vceencc_hevc/command_builder.py @@ -13,6 +13,7 @@ rigaya_avformat_reader, rigaya_auto_options, rigaya_trim_or_seek, + rigaya_extra_options, pa_builder, ) @@ -63,11 +64,11 @@ def build(fastflix: FastFlix): flip_x = "true" if video.video_settings.horizontal_flip else "false" flip_y = "true" if video.video_settings.vertical_flip else "false" command.extend(["--vpp-transform", f"flip_x={flip_x},flip_y={flip_y}"]) - if video.scale: - command.extend(["--output-res", video.scale.replace(":", "x")]) if video.video_settings.crop: crop = video.video_settings.crop command.extend(["--crop", f"{crop.left},{crop.top},{crop.right},{crop.bottom}"]) + if video.output_width is not None and video.output_height is not None: + command.extend(["--output-res", f"{video.output_width}x{video.output_height}"]) if video.video_settings.remove_metadata: command.extend(["--video-metadata", "clear", "--metadata", "clear"]) @@ -77,7 +78,7 @@ def build(fastflix: FastFlix): command.extend(["--video-metadata", "copy", "--metadata", "copy"]) if video.video_settings.video_title: - command.extend(["--video-metadata", f"title={video.video_settings.video_title}"]) + command.extend(["--metadata", f"title={video.video_settings.video_title}"]) if video.video_settings.copy_chapters: command.append("--chapter-copy") @@ -147,6 +148,9 @@ def build(fastflix: FastFlix): else "mobius" ) command.extend(["--vpp-colorspace", f"hdr2sdr={remove_type}"]) + + command.extend(rigaya_extra_options(video)) + if settings.split_mode == "parallel": command.extend(["--parallel", "auto"]) if settings.metrics: @@ -155,7 +159,12 @@ def build(fastflix: FastFlix): command.extend(build_audio(video.audio_tracks, video.streams.audio)) command.extend(build_subtitle(video.subtitle_tracks, video.streams.subtitle, video_height=video.height)) command.extend( - build_data(video.data_tracks, getattr(video.streams, "data", []), getattr(video.streams, "attachment", [])) + build_data( + video.data_tracks, + getattr(video.streams, "data", []), + getattr(video.streams, "attachment", []), + video.video_settings.output_path, + ) ) if settings.extra: diff --git a/fastflix/encoders/vp9/command_builder.py b/fastflix/encoders/vp9/command_builder.py index 22f6dd29..86d3a333 100644 --- a/fastflix/encoders/vp9/command_builder.py +++ b/fastflix/encoders/vp9/command_builder.py @@ -39,6 +39,9 @@ def build(fastflix: FastFlix): if settings.sharpness >= 0: beginning.extend(["-sharpness", str(settings.sharpness)]) + if settings.lossless: + beginning.extend(["-lossless", "1"]) + details = [ "-quality:v", settings.quality, @@ -53,6 +56,11 @@ def build(fastflix: FastFlix): extra = shlex.split(settings.extra) if settings.extra else [] extra_both = shlex.split(settings.extra) if settings.extra and settings.extra_both_passes else [] + if settings.lossless: + # Lossless mode — no rate control needed, VP9 forces quantizer to 0 + command = beginning + ["-speed:v", str(settings.speed)] + details + extra + ending + return [Command(command=command, name="Single pass lossless", exe="ffmpeg")] + if settings.bitrate: if settings.quality == "realtime": return [ diff --git a/fastflix/encoders/vp9/settings_panel.py b/fastflix/encoders/vp9/settings_panel.py index 2a5321ad..f839a339 100644 --- a/fastflix/encoders/vp9/settings_panel.py +++ b/fastflix/encoders/vp9/settings_panel.py @@ -83,6 +83,8 @@ def __init__(self, parent, main, app: FastFlixApp): checkboxes.addLayout(self.init_row_mt()) checkboxes.addStretch(1) checkboxes.addLayout(self.init_fast_first_pass()) + checkboxes.addStretch(1) + checkboxes.addLayout(self.init_lossless()) grid.addLayout(checkboxes, 5, 2, 1, 4) @@ -201,6 +203,31 @@ def init_fast_first_pass(self): def init_single_pass(self): return self._add_check_box(label="Single Pass (CRF)", tooltip="", widget_name="single_pass", opt="single_pass") + def init_lossless(self): + layout = self._add_check_box( + label="Lossless", + widget_name="lossless", + tooltip=( + "Enable lossless encoding mode.\n" + "Produces bit-exact output with no quality loss.\n" + "Rate control options are ignored in lossless mode." + ), + opt="lossless", + connect=lambda: (self._toggle_lossless(), self.main.page_update(build_thumbnail=False)), + ) + return layout + + def _toggle_lossless(self): + enabled = not self.widgets.lossless.isChecked() + self.qp_radio.setEnabled(enabled) + self.bitrate_radio.setEnabled(enabled) + self.widgets.crf.setEnabled(enabled) + self.widgets.custom_crf.setEnabled(enabled) + self.widgets.bitrate.setEnabled(enabled) + self.widgets.custom_bitrate.setEnabled(enabled) + self.widgets.single_pass.setEnabled(enabled) + self.widgets.fast_first_pass.setEnabled(enabled) + def init_auto_alt_ref(self): return self._add_combo_box( label="Alt Ref Frames", @@ -293,6 +320,7 @@ def update_video_encoder_settings(self): tune_content=self.widgets.tune_content.currentText(), aq_mode=aq_mode, sharpness=sharpness, + lossless=self.widgets.lossless.isChecked(), ) encode_type, q_value = self.get_mode_settings() settings.crf = q_value if encode_type == "qp" else None diff --git a/fastflix/ff_queue.py b/fastflix/ff_queue.py index 027f4a1b..13715587 100644 --- a/fastflix/ff_queue.py +++ b/fastflix/ff_queue.py @@ -3,6 +3,7 @@ import logging import os import shutil +from datetime import datetime import tempfile import threading import time @@ -259,6 +260,20 @@ def get_queue(queue_file: Path) -> list[Video]: if "_generation" in loaded: set_current_generation(queue_file, loaded["_generation"]) + if "queue" not in loaded: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + error_file = queue_file.parent / f"queue_error_{timestamp}.yaml" + try: + shutil.copy2(queue_file, error_file) + logger.warning( + f"Queue file is missing 'queue' key, cannot load. " + f"Saved copy to: {error_file} — " + f"please raise an issue at https://github.com/cdgriffith/FastFlix/issues and attach this file" + ) + except OSError: + logger.warning("Queue file is missing 'queue' key, cannot load. Could not save a copy of the file.") + return [] + queue = [] for video in loaded["queue"]: video["source"] = Path(video["source"]) diff --git a/fastflix/flix.py b/fastflix/flix.py index 63feff89..4ea0fe7e 100644 --- a/fastflix/flix.py +++ b/fastflix/flix.py @@ -5,7 +5,7 @@ import re from pathlib import Path from subprocess import PIPE, CompletedProcess, Popen, TimeoutExpired, run, check_output -from typing import List, Tuple, Union +from typing import List, Optional, Tuple, Union from packaging import version import shlex @@ -412,12 +412,46 @@ def generate_thumbnail_command( "unofficial", "-frames:v", "1", + "-update", + "1", clean_file_string(output), ] return command +def parse_cropdetect_output(stderr: str, video_width: int, video_height: int) -> Optional[List[int]]: + """Parse FFmpeg cropdetect output and return crop margins [right, bottom, left, top]. + + Collects all detected crop rectangles as complete (w, h, x, y) tuples + and selects the most conservative crop (largest detected content area). + Returns None if no valid detections found. + """ + detections = [] + for line in stderr.splitlines(): + if line.startswith("[Parsed_cropdetect"): + try: + w, h, x, y = [int(v) for v in line.rsplit("=")[1].split(":")] + detections.append((w, h, x, y)) + except (ValueError, IndexError): + continue + + if not detections: + return None + + # Select the detection with the largest content area (most conservative crop) + best = max(detections, key=lambda d: d[0] * d[1]) + w, h, x, y = best + + right = video_width - w - x + bottom = video_height - h - y + + if right < 0 or bottom < 0 or x < 0 or y < 0: + return None + + return [right, bottom, x, y] + + def get_auto_crop( config: Config, source: Path, @@ -449,24 +483,12 @@ def get_auto_crop( ] ) - width, height, x_crop, y_crop = None, None, None, None if not output.stderr: - return 0, 0, 0, 0 + return - for line in output.stderr.splitlines(): - if line.startswith("[Parsed_cropdetect"): - w, h, x, y = [int(x) for x in line.rsplit("=")[1].split(":")] - if (not x_crop or (x_crop and x > x_crop)) and (not width or (width and w < width)): - width = w - x_crop = x - if (not height or (height and h < height)) and (not y_crop or (y_crop and y > y_crop)): - height = h - y_crop = y - - if None in (width, height, x_crop, y_crop): - return 0, 0, 0, 0 - - result_list.append([video_width - width - x_crop, video_height - height - y_crop, x_crop, y_crop]) + crop_margins = parse_cropdetect_output(output.stderr, video_width, video_height) + if crop_margins is not None: + result_list.append(crop_margins) def detect_interlaced(app: FastFlixApp, config: Config, source: Path, **_): diff --git a/fastflix/models/config.py b/fastflix/models/config.py index e9081662..e1cc019a 100644 --- a/fastflix/models/config.py +++ b/fastflix/models/config.py @@ -294,9 +294,12 @@ class Config(BaseModel): sticky_tabs: bool = False show_complete_message: bool = False show_error_message: bool = True + keep_source_after_encode: bool = False disable_cover_extraction: bool = False suppress_ffmpeg_version_warning: bool = False + suppress_video_speed_warning: bool = False + suppress_reverse_video_warning: bool = False # PGS to SRT OCR Settings enable_pgs_ocr: bool = False diff --git a/fastflix/models/encode.py b/fastflix/models/encode.py index 5746f661..0ce94e7e 100644 --- a/fastflix/models/encode.py +++ b/fastflix/models/encode.py @@ -16,6 +16,7 @@ class AudioTrack(BaseModel): conversion_aq: Optional[int] = None conversion_bitrate: Optional[str] = None conversion_codec: str = "" + conversion_profile: Optional[str] = None profile: Optional[str] = None enabled: bool = True original: bool = False @@ -128,6 +129,7 @@ class x264Settings(EncoderSettings): bitrate: Optional[str] = None bitrate_passes: int = 2 x264_params: list[str] = Field(default_factory=list) + lossless: bool = False class FFmpegNVENCSettings(EncoderSettings): @@ -295,6 +297,7 @@ class QSVEncCSettings(EncoderSettings): copy_hdr10: bool = False copy_dv: bool = False split_mode: str = "none" + tune: Optional[str] = None @field_validator("cqp", mode="before") @classmethod @@ -330,6 +333,7 @@ class QSVEncCAV1Settings(EncoderSettings): copy_hdr10: bool = False copy_dv: bool = False split_mode: str = "none" + tune: Optional[str] = None @field_validator("cqp", mode="before") @classmethod @@ -363,6 +367,7 @@ class QSVEncCH264Settings(EncoderSettings): adapt_cqm: bool = False adapt_ltr: bool = False split_mode: str = "none" + tune: Optional[str] = None @field_validator("cqp", mode="before") @classmethod @@ -573,6 +578,7 @@ class SVTAV1Settings(EncoderSettings): qp_mode: str = "crf" bitrate: Optional[str] = None svtav1_params: list[str] = Field(default_factory=list) + lossless: bool = False class SVTAVIFSettings(EncoderSettings): @@ -604,6 +610,7 @@ class VP9Settings(EncoderSettings): tune_content: str = "default" aq_mode: int = -1 # -1 = codec default sharpness: int = -1 # -1 = codec default, 0-7 + lossless: bool = False class HEVCVideoToolboxSettings(EncoderSettings): @@ -644,7 +651,9 @@ class AOMAV1Settings(EncoderSettings): aq_mode: str = "default" # default, 0=none, 1=variance, 2=complexity, 3=cyclic crf: Optional[Union[int, float]] = 26 bitrate: Optional[str] = None + single_pass: bool = True aom_params: list[str] = Field(default_factory=list) + lossless: bool = False class WebPSettings(EncoderSettings): diff --git a/fastflix/models/profiles.py b/fastflix/models/profiles.py index 60c76fb1..10ed7d04 100644 --- a/fastflix/models/profiles.py +++ b/fastflix/models/profiles.py @@ -48,6 +48,8 @@ class MatchItem(Enum): TRACK = 3 LANGUAGE = 4 CHANNELS = 5 + CODEC = 6 + CODEC_PROFILE = 7 class MatchType(Enum): @@ -68,6 +70,7 @@ class AudioMatch(BaseModel): match_item: Union[MatchItem, list[MatchItem]] match_input: str = "*" conversion: Optional[str] = None + conversion_profile: Optional[str] = None bitrate: Optional[str] = None downmix: Optional[Union[str, int]] = None title_mode: Union[TitleMode, list[TitleMode]] = TitleMode.ORIGINAL @@ -124,6 +127,7 @@ class SubtitleMatch(BaseModel): class AdvancedOptions(BaseModel): video_speed: float = 1 + reverse_video: bool = False deblock: Optional[str] = None deblock_size: int = 16 tone_map: str = "hable" @@ -131,6 +135,14 @@ class AdvancedOptions(BaseModel): brightness: Optional[str] = None saturation: Optional[str] = None contrast: Optional[str] = None + gamma: Optional[str] = None + hue: Optional[str] = None + sharpen: Optional[str] = None + faststart: bool = True + deinterlace: bool = False + deinterlace_method_index: int = 0 + deinterlace_mode_index: int = 0 + gop_length: Optional[int] = None maxrate: Optional[int] = None bufsize: Optional[int] = None source_fps: Optional[str] = None @@ -141,6 +153,20 @@ class AdvancedOptions(BaseModel): denoise: Optional[str] = None denoise_type_index: int = 0 denoise_strength_index: int = 0 + vibrance: Optional[str] = None + color_temperature: Optional[str] = None + curves_preset: Optional[str] = None + curves_preset_index: int = 0 + colorbalance: Optional[str] = None + colorbalance_index: int = 0 + unsharp: Optional[str] = None + unsharp_index: int = 0 + deflicker: Optional[str] = None + deflicker_index: int = 0 + pad_aspect: Optional[str] = None + pad_aspect_index: int = 0 + pad_color: str = "black" + lut3d_path: Optional[str] = None class Profile(BaseModel): @@ -173,6 +199,8 @@ class Profile(BaseModel): subtitle_select_preferred_language: Optional[bool] = None subtitle_automatic_burn_in: Optional[bool] = None subtitle_select_first_matching: Optional[bool] = None + subtitle_default_disposition: Optional[str] = None # None=keep source, "clear", "first" + subtitle_forced_disposition: Optional[str] = None # None=keep source, "clear", "first" advanced_options: AdvancedOptions = Field(default_factory=AdvancedOptions) diff --git a/fastflix/models/video.py b/fastflix/models/video.py index 08af2242..f544f91c 100644 --- a/fastflix/models/video.py +++ b/fastflix/models/video.py @@ -104,11 +104,13 @@ class VideoSettings(BaseModel): resolution_method: str = "auto" resolution_custom: str | None = None deinterlace: bool = False + deinterlace_filter: str = "yadif" video_speed: Union[float, int] = 1 + reverse_video: bool = False tone_map: str = "hable" denoise: Optional[str] = None deblock: Optional[str] = None - deblock_size: int = 4 + deblock_size: int = 16 color_space: Optional[str] = None color_transfer: Optional[str] = None color_primaries: Optional[str] = None @@ -120,6 +122,20 @@ class VideoSettings(BaseModel): brightness: Optional[str] = None contrast: Optional[str] = None saturation: Optional[str] = None + gamma: Optional[str] = None + hue: Optional[str] = None + sharpen: Optional[str] = None + vibrance: Optional[str] = None + color_temperature: Optional[str] = None + curves_preset: Optional[str] = None + colorbalance: Optional[str] = None + unsharp: Optional[str] = None + deflicker: Optional[str] = None + pad_aspect: Optional[str] = None + pad_color: str = "black" + lut3d_path: Optional[str] = None + faststart: bool = True + gop_length: Optional[int] = None copy_data: bool = False template_generated_name: str = "" video_encoder_settings: Optional[ @@ -172,14 +188,49 @@ def brightness_to_str(cls, value): @classmethod def contrast_to_str(cls, value): if isinstance(value, (int, float)): - return float(value) + return str(value) return value @field_validator("saturation", mode="before") @classmethod def saturation_to_str(cls, value): if isinstance(value, (int, float)): - return float(value) + return str(value) + return value + + @field_validator("gamma", mode="before") + @classmethod + def gamma_to_str(cls, value): + if isinstance(value, (int, float)): + return str(value) + return value + + @field_validator("hue", mode="before") + @classmethod + def hue_to_str(cls, value): + if isinstance(value, (int, float)): + return str(value) + return value + + @field_validator("sharpen", mode="before") + @classmethod + def sharpen_to_str(cls, value): + if isinstance(value, (int, float)): + return str(value) + return value + + @field_validator("vibrance", mode="before") + @classmethod + def vibrance_to_str(cls, value): + if isinstance(value, (int, float)): + return str(value) + return value + + @field_validator("color_temperature", mode="before") + @classmethod + def color_temperature_to_str(cls, value): + if isinstance(value, (int, float)): + return str(value) return value @@ -327,20 +378,114 @@ def sar(self): return "" return stream.get("sample_aspect_ratio", "1:1") + @staticmethod + def compute_output_dimensions( + source_w: int, + source_h: int, + crop_top: int = 0, + crop_bottom: int = 0, + crop_left: int = 0, + crop_right: int = 0, + method: str = "auto", + custom: Optional[str] = None, + ) -> Tuple[Optional[int], Optional[int]]: + """Compute final output width and height after crop + scale + rounding. + + Returns (None, None) if method is "auto" or inputs are invalid. + Returns (width, height) with the auto-calculated dimension rounded to nearest multiple of 8. + """ + cropped_w = source_w - crop_left - crop_right + cropped_h = source_h - crop_top - crop_bottom + + if cropped_w <= 0 or cropped_h <= 0: + return None, None + + if method == "auto" or not custom: + return None, None + + try: + if method == "custom": + parts = custom.split(":") + if len(parts) == 2: + w, h = int(parts[0]), int(parts[1]) + return (w, h) if w > 0 and h > 0 else (None, None) + return None, None + + pixels = int(custom) + if pixels <= 0: + return None, None + + if method == "width": + out_w = pixels + out_h = ((cropped_h * pixels // cropped_w) // 8) * 8 + return out_w, max(out_h, 8) + + if method == "height": + out_h = pixels + out_w = ((cropped_w * pixels // cropped_h) // 8) * 8 + return max(out_w, 8), out_h + + if method == "long edge": + if cropped_w >= cropped_h: + out_w = pixels + out_h = ((cropped_h * pixels // cropped_w) // 8) * 8 + else: + out_h = pixels + out_w = ((cropped_w * pixels // cropped_h) // 8) * 8 + return max(out_w, 8), max(out_h, 8) + except (ValueError, ZeroDivisionError): + return None, None + + return None, None + + @property + def cropped_width(self) -> int: + if not self.video_settings.crop: + return self.width + return self.width - self.video_settings.crop.left - self.video_settings.crop.right + + @property + def cropped_height(self) -> int: + if not self.video_settings.crop: + return self.height + return self.height - self.video_settings.crop.top - self.video_settings.crop.bottom + + @property + def output_width(self) -> Optional[int]: + crop = self.video_settings.crop + w, _ = self.compute_output_dimensions( + source_w=self.width, + source_h=self.height, + crop_top=crop.top if crop else 0, + crop_bottom=crop.bottom if crop else 0, + crop_left=crop.left if crop else 0, + crop_right=crop.right if crop else 0, + method=self.video_settings.resolution_method, + custom=self.video_settings.resolution_custom, + ) + return w + + @property + def output_height(self) -> Optional[int]: + crop = self.video_settings.crop + _, h = self.compute_output_dimensions( + source_w=self.width, + source_h=self.height, + crop_top=crop.top if crop else 0, + crop_bottom=crop.bottom if crop else 0, + crop_left=crop.left if crop else 0, + crop_right=crop.right if crop else 0, + method=self.video_settings.resolution_method, + custom=self.video_settings.resolution_custom, + ) + return h + @property - def scale(self): - if self.video_settings.resolution_method == "auto": + def scale(self) -> Optional[str]: + ow = self.output_width + oh = self.output_height + if ow is None or oh is None: return None - if self.video_settings.resolution_method == "custom": - return self.video_settings.resolution_custom - if self.video_settings.resolution_method == "long edge": - if self.width > self.height: - return f"{self.video_settings.resolution_custom}:-8" - else: - return f"-8:{self.video_settings.resolution_custom}" - if self.video_settings.resolution_method == "width": - return f"{self.video_settings.resolution_custom}:-8" - else: - return f"-8:{self.video_settings.resolution_custom}" + return f"{ow}:{oh}" model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/fastflix/naming.py b/fastflix/naming.py index f4d58506..ec7ac41e 100644 --- a/fastflix/naming.py +++ b/fastflix/naming.py @@ -259,11 +259,10 @@ def resolve_pre_encode_variables( # Output resolution if video_settings is not None and video is not None: - scale = video.scale - if scale: - variables["resolution"] = scale.replace(":", "x").replace("-8", "auto") + if video.output_width is not None and video.output_height is not None: + variables["resolution"] = f"{video.output_width}x{video.output_height}" else: - variables["resolution"] = f"{video.width}x{video.height}" + variables["resolution"] = f"{video.cropped_width}x{video.cropped_height}" else: variables["resolution"] = variables.get("source_resolution", "N-A") diff --git a/fastflix/shared.py b/fastflix/shared.py index c2d55239..a379f3e2 100644 --- a/fastflix/shared.py +++ b/fastflix/shared.py @@ -16,6 +16,11 @@ from pathvalidate import sanitize_filepath +from PySide6 import QtCore, QtGui, QtWidgets + +from fastflix.language import t +from fastflix.resources import get_bool_env + try: # PyInstaller creates a temp folder and stores path in _MEIPASS # noinspection PyUnresolvedReferences @@ -25,10 +30,11 @@ base_path = os.path.abspath(".") pyinstaller = False -from PySide6 import QtGui, QtWidgets +# Detect if running from the Go launcher (embeddable Python distribution) +go_launcher = os.environ.get("FASTFLIX_BUNDLED") == "1" -from fastflix.language import t -from fastflix.resources import get_bool_env +# True when running as any kind of packaged distribution (PyInstaller or Go launcher) +bundled_mode = pyinstaller or go_launcher DEVMODE = get_bool_env("DEVMODE") @@ -153,15 +159,17 @@ def yes_no_message(msg, title=None, yes_text=t("Yes"), no_text=t("No"), yes_acti dialog._button_clicked = None layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(20, 15, 20, 15) + layout.setSpacing(15) label = QtWidgets.QLabel(msg) - label.setWordWrap(True) + label.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(label) button_layout = QtWidgets.QHBoxLayout() no_button = QtWidgets.QPushButton(no_text) no_button.setMinimumHeight(30) - no_button.setStyleSheet("QPushButton { background-color: #F44336; color: white; padding: 6px 20px; }") + no_button.setStyleSheet("QPushButton { background-color: #555555; color: white; padding: 6px 20px; }") def on_no(): dialog._button_clicked = False @@ -171,7 +179,7 @@ def on_no(): yes_button = QtWidgets.QPushButton(yes_text) yes_button.setMinimumHeight(30) - yes_button.setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 6px 20px; }") + yes_button.setStyleSheet("QPushButton { background-color: #4a9eed; color: white; padding: 6px 20px; }") def on_yes(): dialog._button_clicked = True @@ -185,6 +193,9 @@ def on_yes(): layout.addLayout(button_layout) dialog.setLayout(layout) + dialog.setWindowIcon(QtGui.QIcon(my_data)) + dialog.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + dialog.adjustSize() dialog.exec() if dialog._button_clicked is True: diff --git a/fastflix/ui_constants.py b/fastflix/ui_constants.py index 4d331c62..6200397e 100644 --- a/fastflix/ui_constants.py +++ b/fastflix/ui_constants.py @@ -22,7 +22,6 @@ class BaseWidths: FLIP_DROPDOWN: int = 120 ROTATE_DROPDOWN: int = 130 PREVIEW_MIN: int = 330 - OUTPUT_TYPE: int = 60 VIDEO_TRACK_LABEL: int = 75 ENCODER_LABEL: int = 50 RESOLUTION_LABEL: int = 70 diff --git a/fastflix/ui_styles.py b/fastflix/ui_styles.py index 333ccf9d..dbd910da 100644 --- a/fastflix/ui_styles.py +++ b/fastflix/ui_styles.py @@ -12,7 +12,7 @@ # Onyx theme color constants ONYX_COLORS = { "primary": "#567781", # Blue accent (borders, selected tabs) - "input_bg": "#4a555e", # Input field backgrounds + "input_bg": "#434c54", # Input field backgrounds "dropdown_bg": "#4e6172", # Dropdown backgrounds "text": "#ffffff", # White text "text_muted": "#b5b5b5", # Muted/disabled text @@ -42,12 +42,12 @@ def get_scaled_stylesheet(theme: str) -> str: QComboBox QAbstractItemView {{ background-color: #1d2023; border: 2px solid #76797c; }} QPushButton {{ border-radius: {border_radius}px; }} QLineEdit {{ - background-color: #4a555e; + background-color: {ONYX_COLORS["input_bg"]}; color: white; border-radius: {border_radius}px; min-height: {input_min_height}px; }} - QTextEdit {{ background-color: #4a555e; color: white; }} + QTextEdit {{ background-color: {ONYX_COLORS["input_bg"]}; color: white; }} QTabBar::tab {{ background-color: #4f5962; }} QComboBox {{ border-radius: {border_radius}px; min-height: {input_min_height}px; }} QScrollArea {{ border: 1px solid #919191; }} diff --git a/fastflix/version.py b/fastflix/version.py index 5634478b..fd478625 100644 --- a/fastflix/version.py +++ b/fastflix/version.py @@ -1,4 +1,4 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -__version__ = "6.2.1" +__version__ = "6.3.0" __author__ = "Chris Griffith" diff --git a/fastflix/widgets/about.py b/fastflix/widgets/about.py index 59125047..529db3aa 100644 --- a/fastflix/widgets/about.py +++ b/fastflix/widgets/about.py @@ -7,7 +7,7 @@ from PySide6 import QtCore, QtGui, QtWidgets from fastflix.language import t -from fastflix.shared import base_path, link, pyinstaller +from fastflix.shared import base_path, bundled_mode, go_launcher, link from fastflix.version import __version__ __all__ = ["About"] @@ -63,14 +63,23 @@ def __init__(self, app): supporting_libraries_label.setOpenExternalLinks(True) layout.addWidget(supporting_libraries_label) - if pyinstaller: - pyinstaller_label = QtWidgets.QLabel( - f"{t('Packaged with')}: {link('https://www.pyinstaller.org/index.html', 'PyInstaller', app.fastflix.config.theme)}" - ) - pyinstaller_label.setAlignment(QtCore.Qt.AlignCenter) - pyinstaller_label.setOpenExternalLinks(True) + if bundled_mode: + if go_launcher: + packaged_text = ( + f"{t('Packaged with')}: " + f"{link('https://go.dev/', 'Go Launcher', app.fastflix.config.theme)} + " + f"{link('https://www.python.org/', t('Embeddable Python'), app.fastflix.config.theme)}" + ) + else: + packaged_text = ( + f"{t('Packaged with')}: " + f"{link('https://www.pyinstaller.org/index.html', 'PyInstaller', app.fastflix.config.theme)}" + ) + packaged_label = QtWidgets.QLabel(packaged_text) + packaged_label.setAlignment(QtCore.Qt.AlignCenter) + packaged_label.setOpenExternalLinks(True) layout.addWidget(QtWidgets.QLabel()) - layout.addWidget(pyinstaller_label) + layout.addWidget(packaged_label) license_label = QtWidgets.QLabel( link( diff --git a/fastflix/widgets/background_tasks.py b/fastflix/widgets/background_tasks.py index 0641ae5f..317faf86 100644 --- a/fastflix/widgets/background_tasks.py +++ b/fastflix/widgets/background_tasks.py @@ -44,15 +44,24 @@ def run(self): self.main.thread_logging_signal.emit(f"DEBUG:{t('Generating thumbnail')}: {_format_command(self.command)}") result = run(self.command, stdin=PIPE, stdout=PIPE, stderr=STDOUT) if result.returncode > 0: - if "No such filter: 'zscale'" in result.stdout.decode(encoding="utf-8", errors="ignore"): + output_text = result.stdout.decode(encoding="utf-8", errors="ignore") + if "No such filter: 'zscale'" in output_text: self.main.thread_logging_signal.emit( "ERROR:Could not generate thumbnail because you are using an outdated FFmpeg! " "Please use FFmpeg 4.3+ built against the latest zimg libraries. " "Static builds available at https://ffmpeg.org/download.html " ) - if "OpenCL mapping not usable" in result.stdout.decode(encoding="utf-8", errors="ignore"): + if "OpenCL mapping not usable" in output_text: self.main.thread_logging_signal.emit("ERROR trying to use OpenCL for thumbnail generation") self.main.thumbnail_complete.emit(2) + return + elif "no path between colorspaces" in output_text: + self.main.thread_logging_signal.emit( + "WARNING:HDR tonemapping failed for thumbnail (video color metadata may be incomplete), " + "retrying without HDR conversion" + ) + self.main.thumbnail_complete.emit(3) + return else: self.main.thread_logging_signal.emit(f"ERROR:{t('Could not generate thumbnail')}: {result.stdout}") diff --git a/fastflix/widgets/container.py b/fastflix/widgets/container.py index 22431b0a..c4326aa2 100644 --- a/fastflix/widgets/container.py +++ b/fastflix/widgets/container.py @@ -14,6 +14,7 @@ from fastflix.exceptions import FastFlixInternalException from fastflix.language import t +from fastflix.version import __version__ from fastflix.models.config import setting_types, get_preset_defaults from fastflix.models.fastflix_app import FastFlixApp from fastflix.program_downloads import latest_ffmpeg, grab_stable_ffmpeg, download_hdr10plus_tool @@ -91,6 +92,12 @@ def __init__(self, app: FastFlixApp, **kwargs): self.init_menu() + self.version_label = QtWidgets.QLabel(f"{__version__}", self) + self.version_label.setStyleSheet("color: #808080; background: transparent;") + self.version_label.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) + self.version_label.adjustSize() + self.version_label.raise_() + self.main = Main(self, app) self.status_bar = StatusBarWidget(self.app, parent=self) @@ -213,9 +220,18 @@ def ensure_window_in_bounds(self): # Apply the constrained geometry self.setGeometry(new_x, new_y, new_width, new_height) + def _position_version_label(self): + menubar = self.menuBar() + menubar_height = menubar.height() + y = (menubar_height - self.version_label.height()) // 2 + x = self.width() - self.version_label.width() - 8 + self.version_label.move(x, y) + self.version_label.raise_() + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: """Handle resize events to ensure window stays within screen bounds and update scaling.""" super().resizeEvent(event) + self._position_version_label() # Always update scale factors and styles so the UI adapts to any window size scaler.calculate_factors(event.size().width(), event.size().height()) diff --git a/fastflix/widgets/main.py b/fastflix/widgets/main.py index f417894c..df48451e 100644 --- a/fastflix/widgets/main.py +++ b/fastflix/widgets/main.py @@ -28,6 +28,7 @@ get_concat_item, ) from fastflix.language import t +from fastflix.widgets.toggle_switch import ToggleSwitch from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import Video, VideoSettings, Crop from fastflix.resources import ( @@ -66,14 +67,14 @@ t("Width"): {"method": "width"}, t("Height"): {"method": "height"}, t("Custom (w:h)"): {"method": "custom"}, - "4320 LE": {"method": "long edge", "pixels": 4320}, - "2160 LE": {"method": "long edge", "pixels": 2160}, + "8K (4320 LE)": {"method": "long edge", "pixels": 4320}, + "4K (2160 LE)": {"method": "long edge", "pixels": 2160}, "1920 LE": {"method": "long edge", "pixels": 1920}, "1440 LE": {"method": "long edge", "pixels": 1440}, "1280 LE": {"method": "long edge", "pixels": 1280}, - "1080 LE": {"method": "long edge", "pixels": 1080}, - "720 LE": {"method": "long edge", "pixels": 720}, - "480 LE": {"method": "long edge", "pixels": 480}, + "1080p (1080 LE)": {"method": "long edge", "pixels": 1080}, + "720p (720 LE)": {"method": "long edge", "pixels": 720}, + "480p (480 LE)": {"method": "long edge", "pixels": 480}, "4320 H": {"method": "height", "pixels": 4320}, "2160 H": {"method": "height", "pixels": 2160}, "1920 H": {"method": "height", "pixels": 1920}, @@ -81,11 +82,11 @@ "1080 H": {"method": "height", "pixels": 1080}, "720 H": {"method": "height", "pixels": 720}, "480 H": {"method": "height", "pixels": 480}, - "7680 W": {"method": "width", "pixels": 7680}, - "3840 W": {"method": "width", "pixels": 3840}, - "2560 W": {"method": "width", "pixels": 2560}, - "1920 W": {"method": "width", "pixels": 1920}, - "1280 W": {"method": "width", "pixels": 1280}, + "8K (7680 W)": {"method": "width", "pixels": 7680}, + "4K (3840 W)": {"method": "width", "pixels": 3840}, + "1440p (2560 W)": {"method": "width", "pixels": 2560}, + "1080p (1920 W)": {"method": "width", "pixels": 1920}, + "720p (1280 W)": {"method": "width", "pixels": 1280}, "1024 W": {"method": "width", "pixels": 1024}, "640 W": {"method": "width", "pixels": 640}, } @@ -114,15 +115,15 @@ class MainWidgets(BaseModel): flip: QtWidgets.QComboBox = None crop: CropWidgets = Field(default_factory=CropWidgets) scale: ScaleWidgets = Field(default_factory=ScaleWidgets) - remove_metadata: QtWidgets.QCheckBox = None - chapters: QtWidgets.QCheckBox = None + remove_metadata: ToggleSwitch = None + chapters: ToggleSwitch = None fast_time: QtWidgets.QComboBox = None preview: QtWidgets.QLabel = None convert_to: QtWidgets.QComboBox = None convert_button: QtWidgets.QPushButton = None queue_button: QtWidgets.QPushButton = None - deinterlace: QtWidgets.QCheckBox = None - remove_hdr: QtWidgets.QCheckBox = None + deinterlace: ToggleSwitch = None + remove_hdr: ToggleSwitch = None profile_box: QtWidgets.QComboBox = None thumb_time: QtWidgets.QSlider = None preview_time_label: QtWidgets.QLabel = None @@ -190,6 +191,7 @@ def __init__(self, parent, app: FastFlixApp): self.setAcceptDrops(True) self.input_video = None + self.skip_hdr_thumbnail = False self.video_path_widget = QtWidgets.QLineEdit(t("No Source Selected")) motto = "" if self.app.fastflix.config.language == "eng": @@ -228,6 +230,7 @@ def __init__(self, parent, app: FastFlixApp): self.source_video_path_widget.setStyleSheet( f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" ) + self.source_video_path_widget.textChanged.connect(self.source_video_path_widget.setToolTip) self.output_video_path_widget = QtWidgets.QLineEdit("") self.output_video_path_widget.setDisabled(True) @@ -236,6 +239,7 @@ def __init__(self, parent, app: FastFlixApp): f"padding: 0 0 -1px 5px; color: rgb({get_text_color(self.app.fastflix.config.theme)})" ) self.output_video_path_widget.setMaxLength(250) + self.output_video_path_widget.textChanged.connect(lambda: self.update_output_tooltip()) self._filename_was_truncated = False self.filename_truncation_warning = QtWidgets.QLabel() @@ -612,8 +616,8 @@ def init_video_area(self): self.clear_source_button.setDisabled(True) if self.app.fastflix.config.theme == "onyx": self.clear_source_button.setStyleSheet( - "QPushButton { color: #F44336; border: none; font-weight: bold; }" - "QPushButton:hover { background-color: #3a3a3a; border-radius: 4px; }" + "QPushButton { color: #cccccc; border: none; font-weight: bold; }" + "QPushButton:hover { color: #ffffff; background-color: #3a3a3a; border-radius: 4px; }" ) else: self.clear_source_button.setStyleSheet( @@ -634,13 +638,15 @@ def init_video_area(self): output_layout.addWidget(output_label) output_layout.addWidget(self.output_video_path_widget, stretch=True) - self.widgets.output_type_combo.setFixedWidth(scaler.scale(WIDTHS.OUTPUT_TYPE)) + self.widgets.output_type_combo.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) + self.widgets.output_type_combo.addItem(t("Source")) if self.current_encoder: self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) self.widgets.output_type_combo.setMinimumHeight(scaler.scale(HEIGHTS.COMBO_BOX)) if self.app.fastflix.config.theme == "onyx": self.widgets.output_type_combo.setStyleSheet(get_onyx_combobox_style()) self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=False)) + self.widgets.output_type_combo.currentIndexChanged.connect(lambda: self.update_output_tooltip()) output_layout.addWidget(self.widgets.output_type_combo) @@ -990,26 +996,27 @@ def init_options_tabs(self): opts_layout.setSpacing(scaler.scale(4)) opts_layout.setContentsMargins(scaler.scale(8), scaler.scale(8), scaler.scale(8), scaler.scale(8)) - self.widgets.remove_metadata = QtWidgets.QCheckBox(t("Remove Metadata")) + self.widgets.remove_metadata = ToggleSwitch(t("Remove Metadata")) self.widgets.remove_metadata.setChecked(True) self.widgets.remove_metadata.toggled.connect(self.page_update) self.widgets.remove_metadata.setToolTip( t("Scrub away all incoming metadata, like video titles, unique markings and so on.") ) - self.widgets.chapters = QtWidgets.QCheckBox(t("Copy Chapters")) + self.widgets.chapters = ToggleSwitch(t("Copy Chapters")) self.widgets.chapters.setChecked(True) self.widgets.chapters.toggled.connect(self.page_update) self.widgets.chapters.setToolTip(t("Copy the chapter markers as is from incoming source.")) - self.widgets.deinterlace = QtWidgets.QCheckBox(t("Deinterlace")) + self.widgets.deinterlace = ToggleSwitch(t("Deinterlace")) self.widgets.deinterlace.setChecked(False) self.widgets.deinterlace.toggled.connect(self.interlace_update) self.widgets.deinterlace.setToolTip( - f"{t('Enables the yadif filter.')}\n{t('Automatically enabled when an interlaced video is detected')}" + f"{t('Enables deinterlacing (method selectable in Advanced panel).')}\n" + f"{t('Automatically enabled when an interlaced video is detected')}" ) - self.widgets.remove_hdr = QtWidgets.QCheckBox(t("Remove HDR")) + self.widgets.remove_hdr = ToggleSwitch(t("Remove HDR")) self.widgets.remove_hdr.setChecked(False) self.widgets.remove_hdr.toggled.connect(self.hdr_update) self.widgets.remove_hdr.setToolTip( @@ -1103,7 +1110,7 @@ def set_profile(self): ) self.widgets.remove_hdr.setChecked(self.app.fastflix.config.opt("remove_hdr")) - # self.widgets.deinterlace.setChecked(self.app.fastflix.config.opt("deinterlace")) + self.widgets.deinterlace.setChecked(self.app.fastflix.config.advanced_opt("deinterlace")) self.widgets.chapters.setChecked(self.app.fastflix.config.opt("copy_chapters")) self.widgets.remove_metadata.setChecked(self.app.fastflix.config.opt("remove_metadata")) @@ -1252,8 +1259,45 @@ def update_output_type(self): self.widgets.output_type_combo.clear() if not self.current_encoder: return + self.widgets.output_type_combo.addItem(t("Source")) self.widgets.output_type_combo.addItems(self.current_encoder.video_extensions) - self.widgets.output_type_combo.setCurrentText(self.app.fastflix.config.opt("output_type")) + saved = self.app.fastflix.config.opt("output_type") + if saved == "same_as_source": + self.widgets.output_type_combo.setCurrentIndex(0) + else: + self.widgets.output_type_combo.setCurrentText(saved) + + def resolve_output_extension(self) -> str: + """Resolve the actual output file extension, handling 'Source' option.""" + combo_text = self.widgets.output_type_combo.currentText() + if combo_text == t("Source"): + if self.app.fastflix.current_video: + source_ext = self.app.fastflix.current_video.source.suffix.lower() + if self.current_encoder and source_ext in self.current_encoder.video_extensions: + return source_ext + # Source ext not supported by encoder — fall back to first supported + if self.current_encoder and self.current_encoder.video_extensions: + return self.current_encoder.video_extensions[0] + return ".mkv" + return combo_text + + def update_output_tooltip(self): + directory = self.widgets.output_directory.text() + name = self.output_video_path_widget.text() + if directory and name: + try: + ext = self.resolve_output_extension() + except Exception: + ext = "" + self.output_video_path_widget.setToolTip(str(Path(directory) / f"{name}{ext}")) + else: + self.output_video_path_widget.setToolTip("") + + def output_type_for_profile(self) -> str: + """Return the output type value to store in profiles.""" + if self.widgets.output_type_combo.currentText() == t("Source"): + return "same_as_source" + return self.widgets.output_type_combo.currentText() @property def current_encoder(self): @@ -1320,53 +1364,33 @@ def update_resolution_labels(self): src_h = self.app.fastflix.current_video.height self.widgets.video_res_label.setText(t("Video Resolution") + f": {src_w}w {src_h}h") - # Start with source dimensions, apply crop - out_w = src_w - out_h = src_h try: crop_top = int(self.widgets.crop.top.text() or 0) crop_bottom = int(self.widgets.crop.bottom.text() or 0) crop_left = int(self.widgets.crop.left.text() or 0) crop_right = int(self.widgets.crop.right.text() or 0) - out_w -= crop_left + crop_right - out_h -= crop_top + crop_bottom except (ValueError, AttributeError): - pass + crop_top = crop_bottom = crop_left = crop_right = 0 - if out_w <= 0 or out_h <= 0: + cropped_w = src_w - crop_left - crop_right + cropped_h = src_h - crop_top - crop_bottom + if cropped_w <= 0 or cropped_h <= 0: self.widgets.output_res_label.setText(t("Output Resolution") + ": --") return - # Apply scale based on resolution method - method = self.resolution_method() - custom = self.resolution_custom() + out_w, out_h = Video.compute_output_dimensions( + source_w=src_w, + source_h=src_h, + crop_top=crop_top, + crop_bottom=crop_bottom, + crop_left=crop_left, + crop_right=crop_right, + method=self.resolution_method(), + custom=self.resolution_custom(), + ) - if method != "auto" and custom: - try: - if method == "custom": - parts = custom.split(":") - if len(parts) == 2: - cw, ch = int(parts[0]), int(parts[1]) - if cw > 0 and ch > 0: - out_w, out_h = cw, ch - elif method == "width": - new_w = int(custom) - out_h = ((out_h * new_w // out_w) // 8) * 8 - out_w = new_w - elif method == "height": - new_h = int(custom) - out_w = ((out_w * new_h // out_h) // 8) * 8 - out_h = new_h - elif method == "long edge": - pixels = int(custom) - if out_w >= out_h: - out_h = ((out_h * pixels // out_w) // 8) * 8 - out_w = pixels - else: - out_w = ((out_w * pixels // out_h) // 8) * 8 - out_h = pixels - except (ValueError, ZeroDivisionError): - pass + if out_w is None or out_h is None: + out_w, out_h = cropped_w, cropped_h self.widgets.output_res_label.setText(t("Output Resolution") + f": {out_w}w {out_h}h") @@ -1541,6 +1565,19 @@ def showEvent(self, event): ) self.crop_preview_button.raise_() + # Thumbnail warning indicator at top left + self.thumb_warning_label = QtWidgets.QLabel(self.preview_container) + warn_size = scaler.scale(24) + self.thumb_warning_label.setFixedSize(warn_size, warn_size) + self.thumb_warning_label.setPixmap( + QtGui.QIcon(get_icon("onyx-warning", self.app.fastflix.config.theme)).pixmap(warn_size, warn_size) + ) + self.thumb_warning_label.setStyleSheet( + "QLabel { background: rgba(0,0,0,128); border: none; border-radius: 4px; padding: 2px; }" + ) + self.thumb_warning_label.hide() + self.thumb_warning_label.raise_() + return self.preview_container def open_crop_preview(self): @@ -1568,6 +1605,9 @@ def reposition_thumb_overlay(self): self.preview_container.width() - btn_size - btn_margin, btn_margin, ) + if hasattr(self, "thumb_warning_label") and hasattr(self, "preview_container"): + warn_margin = scaler.scale(15) + self.thumb_warning_label.move(warn_margin, warn_margin) def modify_int(self, widget, method="add", time_field=False): modifier = 1 @@ -1623,7 +1663,7 @@ def generate_output_filename(self): extension = "" if self.current_encoder: try: - extension = self.widgets.output_type_combo.currentText() + extension = self.resolve_output_extension() except Exception: extension = self.current_encoder.video_extensions[0] if self.current_encoder.video_extensions else "" name, was_truncated = truncate_filename(name, out_loc, extension) @@ -1646,17 +1686,18 @@ def output_video(self): return clean_file_string( Path( self.widgets.output_directory.text(), - f"{self.output_video_path_widget.text()}{self.widgets.output_type_combo.currentText()}", + f"{self.output_video_path_widget.text()}{self.resolve_output_extension()}", ) ) @reusables.log_exception("fastflix", show_traceback=False) def save_file(self, extension="mkv"): + resolved_ext = self.resolve_output_extension() filename = QtWidgets.QFileDialog.getSaveFileName( self, caption="Save Video As", - dir=str(Path(*self.generate_output_filename)) + f"{self.widgets.output_type_combo.currentText()}", - filter=f"Save File (*.{extension})", + dir=str(Path(*self.generate_output_filename)) + resolved_ext, + filter=f"Save File (*{resolved_ext})", ) if filename and filename[0]: fn = Path(filename[0]) @@ -1671,6 +1712,7 @@ def save_directory(self): ) if dirname: self.widgets.output_directory.setText(dirname.rstrip("/").rstrip("\\")) + self.update_output_tooltip() def get_auto_crop(self): if not self.input_video or not self.initialized or self.loading_video: @@ -1885,14 +1927,20 @@ def generate_thumbnail(self): return settings = self.app.fastflix.current_video.video_settings.model_dump() + settings.pop("reverse_video", None) if ( - self.app.fastflix.current_video.video_settings.video_encoder_settings.pix_fmt == "yuv420p10le" + not self.skip_hdr_thumbnail + and self.app.fastflix.current_video.video_settings.video_encoder_settings.pix_fmt == "yuv420p10le" and self.app.fastflix.current_video.color_space.startswith("bt2020") ): settings["remove_hdr"] = True if not settings.get("color_transfer"): settings["color_transfer"] = self.app.fastflix.current_video.color_transfer + if not settings.get("color_primaries"): + settings["color_primaries"] = self.app.fastflix.current_video.color_primaries + if not settings.get("color_space"): + settings["color_space"] = self.app.fastflix.current_video.color_space custom_filters = "scale='min(440\\,iw):-8'" if self.resolution_method() == "custom": @@ -1940,16 +1988,39 @@ def thread_logger(text): except Exception: logger.warning(text) + def set_thumb_warning(self, message: str): + """Show a warning icon overlay on the thumbnail with a tooltip.""" + self.thumb_warning_label.setToolTip(message) + self.thumb_warning_label.show() + self.thumb_warning_label.raise_() + + def clear_thumb_warning(self): + """Hide the thumbnail warning overlay.""" + self.thumb_warning_label.setToolTip("") + self.thumb_warning_label.hide() + @reusables.log_exception("fastflix", show_traceback=False) def thumbnail_generated(self, status=0): if status == 2: self.app.fastflix.opencl_support = False self.generate_thumbnail() return + if status == 3: + self.skip_hdr_thumbnail = True + self.generate_thumbnail() + return if status == 0 or not status or not self.thumb_file.exists(): + self.set_thumb_warning(t("Thumbnail generation failed - preview may not be available")) self.widgets.preview.setText(t("Error Updating Thumbnail")) return + if self.skip_hdr_thumbnail: + self.set_thumb_warning( + t("HDR tonemapping unavailable for preview - colors in output will differ from this thumbnail") + ) + else: + self.clear_thumb_warning() + pixmap = QtGui.QPixmap(str(self.thumb_file)) pixmap = pixmap.scaled(420, 260, QtCore.Qt.KeepAspectRatio) self.widgets.preview.setPixmap(pixmap) @@ -1990,6 +2061,7 @@ def get_all_settings(self): horizontal_flip=h_flip, output_path=Path(clean_file_string(self.output_video)), deinterlace=self.widgets.deinterlace.isChecked(), + deinterlace_filter=self.video_options.advanced.get_deinterlace_filter(), remove_metadata=self.remove_metadata, copy_chapters=self.copy_chapters, video_title=self.video_options.advanced.video_title.text(), diff --git a/fastflix/widgets/main_encoding.py b/fastflix/widgets/main_encoding.py index c6f8f73e..fe5e0ccf 100644 --- a/fastflix/widgets/main_encoding.py +++ b/fastflix/widgets/main_encoding.py @@ -158,7 +158,8 @@ def add_to_queue(self): return code self.video_options.show_queue() - self.clear_current_video() + if not self.app.fastflix.config.keep_source_after_encode: + self.clear_current_video() return True def conversion_complete(self, success: bool): @@ -265,6 +266,7 @@ def end_encoding(self): self.video_options.update_queue() self.set_convert_button() self.encoding_progress_signal.emit(0) + self.enable_all() def send_next_video(self) -> bool: if not self.app.fastflix.currently_encoding: diff --git a/fastflix/widgets/main_video_load.py b/fastflix/widgets/main_video_load.py index 0b0022af..b69b2560 100644 --- a/fastflix/widgets/main_video_load.py +++ b/fastflix/widgets/main_video_load.py @@ -243,6 +243,9 @@ def reload_video_from_queue(self, video: Video): @reusables.log_exception("fastflix", show_traceback=False) def update_video_info(self, hide_progress=False): self.loading_video = True + self.skip_hdr_thumbnail = False + if hasattr(self, "thumb_warning_label"): + self.clear_thumb_warning() folder, name = self.generate_output_filename self.output_video_path_widget.setText(name) self.widgets.output_directory.setText(folder.rstrip("/").rstrip("\\")) diff --git a/fastflix/widgets/panels/advanced_panel.py b/fastflix/widgets/panels/advanced_panel.py index de2a59b0..50003648 100644 --- a/fastflix/widgets/panels/advanced_panel.py +++ b/fastflix/widgets/panels/advanced_panel.py @@ -7,11 +7,10 @@ from PySide6 import QtCore, QtGui, QtWidgets from fastflix.language import t -from fastflix.shared import shrink_text_to_fit from fastflix.models.fastflix_app import FastFlixApp from fastflix.models.video import VideoSettings -from fastflix.resources import get_icon from fastflix.models.profiles import AdvancedOptions +from fastflix.widgets.toggle_switch import ToggleSwitch from fastflix.ui_styles import get_onyx_label_style from fastflix.flix import ffmpeg_valid_color_primaries, ffmpeg_valid_color_transfers, ffmpeg_valid_color_space @@ -42,6 +41,11 @@ "moderate": "nlmeans=s=1.0:p=7:r=15", "strong": "nlmeans=s=10.0:p=13:r=25", }, + "nlmeans_opencl": { + "weak": "nlmeans_opencl=s=1.0:p=3:r=9", + "moderate": "nlmeans_opencl=s=1.0:p=7:r=15", + "strong": "nlmeans_opencl=s=10.0:p=13:r=25", + }, "atadenoise": { "weak": "atadenoise=0a=0.01:0b=0.02:1a=0.01:1b=0.02:2a=0.01:2b=0.02:s=9", "moderate": "atadenoise=0a=0.02:0b=0.04:1a=0.02:1b=0.04:2a=0.02:2b=0.04:s=9", @@ -59,9 +63,57 @@ }, } +deinterlace_methods = ["yadif", "bwdif", "w3fdif", "estdif"] + +deinterlace_modes = ["send_frame", "send_field"] + +deinterlace_presets = { + "yadif": {"send_frame": "yadif", "send_field": "yadif=mode=1"}, + "bwdif": {"send_frame": "bwdif", "send_field": "bwdif=mode=1"}, + "w3fdif": {"send_frame": "w3fdif", "send_field": "w3fdif=mode=1"}, + "estdif": {"send_frame": "estdif", "send_field": "estdif=mode=1"}, +} + vsync = ["auto", "passthrough", "cfr", "vfr", "drop"] tone_map_items = ["none", "clip", "linear", "gamma", "reinhard", "hable", "mobius"] +unsharp_presets = { + "light": "unsharp=5:5:0.5:5:5:0.0", + "medium": "unsharp=5:5:1.0:5:5:0.5", + "strong": "unsharp=7:7:1.5:7:7:1.0", +} + +deflicker_presets = { + "light": "deflicker=mode=pm:size=3", + "medium": "deflicker=mode=pm:size=5", + "strong": "deflicker=mode=pm:size=11", +} + +colorbalance_presets = { + "Warm Shadows": "colorbalance=rs=0.15:bs=-0.15", + "Cool Shadows": "colorbalance=rs=-0.15:bs=0.15", + "Warm Midtones": "colorbalance=rm=0.15:bm=-0.15", + "Cool Midtones": "colorbalance=rm=-0.15:bm=0.15", + "Warm Highlights": "colorbalance=rh=0.15:bh=-0.15", + "Cool Highlights": "colorbalance=rh=-0.15:bh=0.15", +} + +curves_presets = [ + "none", + "color_negative", + "cross_process", + "darker", + "increase_contrast", + "lighter", + "linear_contrast", + "medium_contrast", + "negative", + "strong_contrast", + "vintage", +] + +pad_aspects = ["none", "16:9", "4:3", "1:1", "9:16", "21:9"] + def non(value): if value.lower() in ( @@ -92,73 +144,80 @@ def __init__(self, parent, app: FastFlixApp): self.updating = False self.only_int = QtGui.QIntValidator() - self.layout = QtWidgets.QGridLayout() - - self.last_row = 0 - - self.init_fps() - self.add_spacer() - self.init_video_speed() - self.add_spacer() - self.init_eq() - self.add_spacer() - self.init_denoise() - self.add_spacer() - self.init_deblock() - self.add_spacer() - self.init_color_info() - self.add_spacer() - self.init_vbv() - self.add_spacer() - self.layout.setRowStretch(self.last_row, True) + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.NoFrame) + + container = QtWidgets.QWidget() + container.setMinimumHeight(860) + self.inner_layout = QtWidgets.QVBoxLayout(container) + self.inner_layout.setSpacing(6) + + self.inner_layout.addWidget(self.init_video_details_group()) + self.inner_layout.addWidget(self.init_fps_group()) + self.inner_layout.addWidget(self.init_video_processing_group()) + self.inner_layout.addWidget(self.init_color_appearance_group()) + self.inner_layout.addWidget(self.init_color_group()) + self.inner_layout.addWidget(self.init_output_group()) + self.inner_layout.addStretch() self.init_hw_message() - self.init_titles() - # self.add_spacer() - # self.init_custom_filters() - # self.last_row += 1 + scroll.setWidget(container) + + self.layout = QtWidgets.QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.addWidget(scroll) + self.setLayout(self.layout) - # self.layout.setColumnStretch(6, True) - self.last_row += 1 + @staticmethod + def setup_grid(gl, cols=6): + for i in range(cols): + gl.setColumnMinimumWidth(i, 120) + gl.setColumnStretch(i, 1) - warning_label = QtWidgets.QLabel() - ico = QtGui.QIcon(get_icon("onyx-warning", app.fastflix.config.theme)) - warning_label.setPixmap(ico.pixmap(22)) + @staticmethod + def setup_group(group, rows): + group.setMinimumHeight(rows * 40 + 30) + group.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - for i in range(1, 7): - self.layout.setColumnMinimumWidth(i, 155) - self.setLayout(self.layout) + def init_video_details_group(self): + group = QtWidgets.QGroupBox(t("Video Details")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) - def add_spacer(self): - self.last_row += 1 - spacer_widget = QtWidgets.QWidget(self) - spacer_widget.setFixedHeight(1) - spacer_widget.setStyleSheet("background-color: #ddd") - self.layout.addWidget(spacer_widget, self.last_row, 0, 1, 7) + self.video_title = QtWidgets.QLineEdit() + self.video_title.setPlaceholderText(t("Video Title")) + self.video_title.textChanged.connect(self.page_update) - def add_row_label(self, label, row_number): - label = QtWidgets.QLabel(label) - label.setFixedWidth(140) - if self.app.fastflix.config.theme == "onyx": - label.setStyleSheet(get_onyx_label_style(muted=True)) - shrink_text_to_fit(label, padding=4) - self.layout.addWidget(label, row_number, 0, alignment=QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + self.video_track_title = QtWidgets.QLineEdit() + self.video_track_title.setPlaceholderText(t("Video Track Title")) + self.video_track_title.textChanged.connect(self.page_update) + + gl.addWidget(QtWidgets.QLabel(t("Video Title")), 0, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.video_title, 0, 1) + gl.addWidget(QtWidgets.QLabel(t("Video Track Title")), 0, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.video_track_title, 0, 3) + + self.setup_group(group, rows=1) + return group + + def init_fps_group(self): + group = QtWidgets.QGroupBox(t("Frame Rate")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) - def init_fps(self): self.incoming_fps_widget = QtWidgets.QLineEdit() - self.incoming_fps_widget.setFixedWidth(150) self.incoming_fps_widget.setDisabled(True) self.incoming_fps_widget.textChanged.connect(self.page_update) self.outgoing_fps_widget = QtWidgets.QLineEdit() - self.outgoing_fps_widget.setFixedWidth(150) self.outgoing_fps_widget.setDisabled(True) self.outgoing_fps_widget.textChanged.connect(self.page_update) - self.incoming_same_as_source = QtWidgets.QCheckBox(t("Same as Source")) + self.incoming_same_as_source = ToggleSwitch(t("Same as Source")) self.incoming_same_as_source.setChecked(True) self.incoming_same_as_source.toggled.connect( lambda: self.fps_update(self.incoming_same_as_source, self.incoming_fps_widget) ) - self.outgoing_same_as_source = QtWidgets.QCheckBox(t("Same as Source")) + self.outgoing_same_as_source = ToggleSwitch(t("Same as Source")) self.outgoing_same_as_source.setChecked(True) self.outgoing_same_as_source.toggled.connect( lambda: self.fps_update(self.outgoing_same_as_source, self.outgoing_fps_widget) @@ -170,100 +229,52 @@ def init_fps(self): self.vsync_widget.addItems(vsync) self.vsync_widget.currentIndexChanged.connect(self.page_update) - self.add_row_label(t("Frame Rate"), self.last_row) + gl.addWidget(QtWidgets.QLabel(t("Override Source FPS")), 0, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.incoming_fps_widget, 0, 1) + gl.addWidget(self.incoming_same_as_source, 0, 2) + gl.addWidget(QtWidgets.QLabel(t("Source Frame Rate")), 0, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.source_frame_rate, 0, 5) - self.layout.addWidget( - QtWidgets.QLabel(t("Override Source FPS")), self.last_row, 1, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.incoming_fps_widget, self.last_row, 2) - self.layout.addWidget(self.incoming_same_as_source, self.last_row, 3) - self.layout.addWidget( - QtWidgets.QLabel(t("Source Frame Rate")), self.last_row, 5, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.source_frame_rate, self.last_row, 6) + gl.addWidget(QtWidgets.QLabel(t("Output FPS")), 1, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.outgoing_fps_widget, 1, 1) + gl.addWidget(self.outgoing_same_as_source, 1, 2) + gl.addWidget(QtWidgets.QLabel(t("vsync")), 1, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.vsync_widget, 1, 5) - self.last_row += 1 - self.layout.addWidget( - QtWidgets.QLabel(t("Output FPS") + " ʘ"), self.last_row, 1, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.outgoing_fps_widget, self.last_row, 2) - self.layout.addWidget(self.outgoing_same_as_source, self.last_row, 3) - - self.layout.addWidget(QtWidgets.QLabel(t("vsync")), self.last_row, 5, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.vsync_widget, self.last_row, 6) - - self.last_row += 1 - - def fps_update(self, myself, widget): - widget.setDisabled(myself.isChecked()) - self.page_update() + self.setup_group(group, rows=2) + return group - # def init_concat(self): - # layout = QtWidgets.QHBoxLayout() - # self.concat_widget = QtWidgets.QCheckBox(t("Combine Files")) - # # TODO add "learn more" link - # - # layout.addWidget(self.concat_widget) - # return layout + def init_video_processing_group(self): + group = QtWidgets.QGroupBox(t("Video Processing")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) + row = 0 - def init_video_speed(self): - self.last_row += 1 + # --- Video Speed / HDR row --- self.video_speed_widget = QtWidgets.QComboBox() self.video_speed_widget.addItems(video_speeds.keys()) - self.video_speed_widget.currentIndexChanged.connect(self.page_update) - self.layout.addWidget( - QtWidgets.QLabel(t("Video Speed") + " ʘ"), self.last_row, 1, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.video_speed_widget, self.last_row, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Warning: Audio will not be modified")), self.last_row, 3, 1, 3) + self.video_speed_widget.currentIndexChanged.connect(self.on_video_speed_changed) + gl.addWidget(QtWidgets.QLabel(t("Video Speed") + " ʘ"), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.video_speed_widget, row, 1) + + self.reverse_video_widget = ToggleSwitch(t("Reverse Video") + " ʘ") + self.reverse_video_widget.stateChanged.connect(self.on_reverse_video_changed) + gl.addWidget(self.reverse_video_widget, row, 3) - # def init_tone_map(self): - # self.last_row += 1 self.tone_map_widget = QtWidgets.QComboBox() self.tone_map_widget.addItems(tone_map_items) self.tone_map_widget.setCurrentIndex(5) self.tone_map_widget.currentIndexChanged.connect(self.page_update) - self.layout.addWidget( - QtWidgets.QLabel(t("HDR -> SDR Tone Map")), self.last_row, 5, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.tone_map_widget, self.last_row, 6) - - def init_eq(self): - self.last_row += 1 - self.brightness_widget = QtWidgets.QLineEdit() - brightness_validator = QtGui.QDoubleValidator() - brightness_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator - self.brightness_widget.setValidator(brightness_validator) - self.brightness_widget.setToolTip("Default is: 0") - self.brightness_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) - - self.contrast_widget = QtWidgets.QLineEdit() - contrast_validator = QtGui.QDoubleValidator() - contrast_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator - self.contrast_widget.setValidator(contrast_validator) - self.contrast_widget.setToolTip("Default is: 1") - self.contrast_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) - - self.saturation_widget = QtWidgets.QLineEdit() - saturation_validator = QtGui.QDoubleValidator() - saturation_validator.setLocale(QtCore.QLocale.c()) # Use C locale to force dot as decimal separator - self.saturation_widget.setValidator(saturation_validator) - self.saturation_widget.setToolTip("Default is: 1") - self.saturation_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) - - self.add_row_label(t("Equalizer") + " ʘ", self.last_row) + gl.addWidget(QtWidgets.QLabel(t("HDR -> SDR Tone Map")), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.tone_map_widget, row, 5) - self.layout.addWidget(QtWidgets.QLabel(t("Brightness")), self.last_row, 1, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.brightness_widget, self.last_row, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Contrast")), self.last_row, 3, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.contrast_widget, self.last_row, 4) - self.layout.addWidget(QtWidgets.QLabel(t("Saturation")), self.last_row, 5, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.saturation_widget, self.last_row, 6) + # --- Denoise row --- + row += 1 - def init_denoise(self): - self.last_row += 1 self.denoise_type_widget = QtWidgets.QComboBox() - self.denoise_type_widget.addItems(["none", "nlmeans", "atadenoise", "hqdn3d", "vaguedenoiser"]) + self.denoise_type_widget.addItems( + ["none", "nlmeans", "nlmeans_opencl", "atadenoise", "hqdn3d", "vaguedenoiser"] + ) self.denoise_type_widget.setCurrentIndex(0) self.denoise_type_widget.currentIndexChanged.connect(self.page_update) @@ -272,14 +283,32 @@ def init_denoise(self): self.denoise_strength_widget.setCurrentIndex(0) self.denoise_strength_widget.currentIndexChanged.connect(self.page_update) - self.add_row_label(t("Denoise") + " ʘ", self.last_row) - self.layout.addWidget(QtWidgets.QLabel(t("Method")), self.last_row, 1, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.denoise_type_widget, self.last_row, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Strength")), self.last_row, 3, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.denoise_strength_widget, self.last_row, 4) + gl.addWidget(QtWidgets.QLabel(t("Denoise")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.denoise_type_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Strength")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.denoise_strength_widget, row, 3) + + # --- Deinterlace row --- + row += 1 + + self.deinterlace_method_widget = QtWidgets.QComboBox() + self.deinterlace_method_widget.addItems(deinterlace_methods) + self.deinterlace_method_widget.setCurrentIndex(0) + self.deinterlace_method_widget.currentIndexChanged.connect(self.page_update) + + self.deinterlace_mode_widget = QtWidgets.QComboBox() + self.deinterlace_mode_widget.addItems(deinterlace_modes) + self.deinterlace_mode_widget.setCurrentIndex(0) + self.deinterlace_mode_widget.currentIndexChanged.connect(self.page_update) + + gl.addWidget(QtWidgets.QLabel(t("Deinterlace")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.deinterlace_method_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Mode")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.deinterlace_mode_widget, row, 3) + + # --- Deblock / Deflicker row --- + row += 1 - def init_deblock(self): - self.last_row += 1 self.deblock_widget = QtWidgets.QComboBox() self.deblock_widget.addItems(["none", "weak", "strong"]) self.deblock_widget.setCurrentIndex(0) @@ -292,14 +321,199 @@ def init_deblock(self): self.deblock_size_widget.currentIndexChanged.connect(self.page_update) self.deblock_size_widget.setCurrentIndex(2) - self.add_row_label(t("Deblock") + " ʘ", self.last_row) - self.layout.addWidget(QtWidgets.QLabel(t("Strength")), self.last_row, 1, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.deblock_widget, self.last_row, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Block Size")), self.last_row, 3, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.deblock_size_widget, self.last_row, 4) + self.deflicker_widget = QtWidgets.QComboBox() + self.deflicker_widget.addItems(["none", "light", "medium", "strong"]) + self.deflicker_widget.setCurrentIndex(0) + self.deflicker_widget.currentIndexChanged.connect(self.page_update) + + gl.addWidget(QtWidgets.QLabel(t("Deblock")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.deblock_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Block Size")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.deblock_size_widget, row, 3) + gl.addWidget(QtWidgets.QLabel(t("Deflicker") + " ʘ"), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.deflicker_widget, row, 5) + + # --- Sharpen / Unsharp / GOP Length row --- + row += 1 + c_locale = QtCore.QLocale.c() + + self.sharpen_widget = QtWidgets.QLineEdit() + sharpen_validator = QtGui.QDoubleValidator(0.0, 1.0, 2) + sharpen_validator.setLocale(c_locale) + self.sharpen_widget.setValidator(sharpen_validator) + self.sharpen_widget.setToolTip("Default is: 0 (range: 0.0 - 1.0)") + self.sharpen_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.unsharp_widget = QtWidgets.QComboBox() + self.unsharp_widget.addItems(["none", "light", "medium", "strong"]) + self.unsharp_widget.setCurrentIndex(0) + self.unsharp_widget.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.gop_length_widget = QtWidgets.QLineEdit() + self.gop_length_widget.setValidator(QtGui.QIntValidator(0, 9999)) + self.gop_length_widget.setToolTip(t("GOP length in frames (leave empty for encoder default)")) + self.gop_length_widget.textChanged.connect(self.page_update) + + gl.addWidget(QtWidgets.QLabel(t("Sharpen")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.sharpen_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Unsharp Mask")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.unsharp_widget, row, 3) + gl.addWidget(QtWidgets.QLabel(t("GOP Length")), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.gop_length_widget, row, 5) + + # --- Pad row --- + row += 1 + + self.pad_aspect_widget = QtWidgets.QComboBox() + self.pad_aspect_widget.addItems(pad_aspects) + self.pad_aspect_widget.setCurrentIndex(0) + self.pad_aspect_widget.currentIndexChanged.connect(self.page_update) + + self.pad_color_widget = QtWidgets.QLineEdit() + self.pad_color_widget.setPlaceholderText("black") + self.pad_color_widget.setToolTip( + t("Pad color (e.g. black, white, 0x000000). FFmpeg only — rigaya always uses black.") + ) + self.pad_color_widget.textChanged.connect(self.page_update) + + gl.addWidget(QtWidgets.QLabel(t("Pad Aspect")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.pad_aspect_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Pad Color")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.pad_color_widget, row, 3) + + self.setup_group(group, rows=6) + return group + + def init_color_appearance_group(self): + group = QtWidgets.QGroupBox(t("Color & Appearance")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) + row = 0 + c_locale = QtCore.QLocale.c() + + # --- Equalizer row 1: Brightness / Contrast / Saturation --- + self.brightness_widget = QtWidgets.QLineEdit() + brightness_validator = QtGui.QDoubleValidator() + brightness_validator.setLocale(c_locale) + self.brightness_widget.setValidator(brightness_validator) + self.brightness_widget.setToolTip("Default is: 0") + self.brightness_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.contrast_widget = QtWidgets.QLineEdit() + contrast_validator = QtGui.QDoubleValidator() + contrast_validator.setLocale(c_locale) + self.contrast_widget.setValidator(contrast_validator) + self.contrast_widget.setToolTip("Default is: 1") + self.contrast_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.saturation_widget = QtWidgets.QLineEdit() + saturation_validator = QtGui.QDoubleValidator() + saturation_validator.setLocale(c_locale) + self.saturation_widget.setValidator(saturation_validator) + self.saturation_widget.setToolTip("Default is: 1") + self.saturation_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + gl.addWidget(QtWidgets.QLabel(t("Brightness")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.brightness_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Contrast")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.contrast_widget, row, 3) + gl.addWidget(QtWidgets.QLabel(t("Saturation")), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.saturation_widget, row, 5) + + # --- Equalizer row 2: Gamma / Hue / Vibrance --- + row += 1 + + self.gamma_widget = QtWidgets.QLineEdit() + gamma_validator = QtGui.QDoubleValidator(0.1, 10.0, 2) + gamma_validator.setLocale(c_locale) + self.gamma_widget.setValidator(gamma_validator) + self.gamma_widget.setToolTip("Default is: 1 (range: 0.1 - 10.0)") + self.gamma_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.hue_widget = QtWidgets.QLineEdit() + hue_validator = QtGui.QDoubleValidator(-180.0, 180.0, 2) + hue_validator.setLocale(c_locale) + self.hue_widget.setValidator(hue_validator) + self.hue_widget.setToolTip("Default is: 0 (range: -180 - 180 degrees)") + self.hue_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.vibrance_widget = QtWidgets.QLineEdit() + vibrance_validator = QtGui.QDoubleValidator(-2.0, 2.0, 2) + vibrance_validator.setLocale(c_locale) + self.vibrance_widget.setValidator(vibrance_validator) + self.vibrance_widget.setToolTip("Default is: 0 (range: -2.0 - 2.0)") + self.vibrance_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + gl.addWidget(QtWidgets.QLabel(t("Gamma")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.gamma_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Hue")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.hue_widget, row, 3) + gl.addWidget(QtWidgets.QLabel(t("Vibrance") + " ʘ"), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.vibrance_widget, row, 5) + + # --- Color Temperature / Curves / Colorbalance row --- + row += 1 + + self.color_temperature_widget = QtWidgets.QLineEdit() + color_temp_validator = QtGui.QIntValidator(1000, 40000) + self.color_temperature_widget.setValidator(color_temp_validator) + self.color_temperature_widget.setToolTip("Default is: 6500 Kelvin (range: 1000 - 40000)") + self.color_temperature_widget.textChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.curves_preset_widget = QtWidgets.QComboBox() + self.curves_preset_widget.addItems(curves_presets) + self.curves_preset_widget.setCurrentIndex(0) + self.curves_preset_widget.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + self.colorbalance_widget = QtWidgets.QComboBox() + self.colorbalance_widget.addItems(["none"] + list(colorbalance_presets.keys())) + self.colorbalance_widget.setCurrentIndex(0) + self.colorbalance_widget.currentIndexChanged.connect(lambda: self.page_update(build_thumbnail=True)) + + gl.addWidget(QtWidgets.QLabel(t("Color Temperature") + " ʘ"), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.color_temperature_widget, row, 1) + gl.addWidget(QtWidgets.QLabel(t("Curves Preset")), row, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.curves_preset_widget, row, 3) + gl.addWidget(QtWidgets.QLabel(t("Colorbalance") + " ʘ"), row, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.colorbalance_widget, row, 5) + + # --- LUT3D row --- + row += 1 + + self.lut3d_path_widget = QtWidgets.QLineEdit() + self.lut3d_path_widget.setPlaceholderText(t("No LUT file selected")) + self.lut3d_path_widget.setReadOnly(True) + self.lut3d_path_widget.textChanged.connect(self.page_update) + + self.lut3d_browse_button = QtWidgets.QPushButton(t("Browse")) + self.lut3d_browse_button.clicked.connect(self.browse_lut3d) + + self.lut3d_clear_button = QtWidgets.QPushButton(t("Clear")) + self.lut3d_clear_button.clicked.connect(lambda: self.lut3d_path_widget.setText("")) + + gl.addWidget(QtWidgets.QLabel(t("LUT3D")), row, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.lut3d_path_widget, row, 1, 1, 3) + gl.addWidget(self.lut3d_browse_button, row, 4) + gl.addWidget(self.lut3d_clear_button, row, 5) + + self.setup_group(group, rows=4) + return group + + def browse_lut3d(self): + file_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + t("Select LUT File"), + "", + t("LUT Files") + " (*.cube *.3dl *.dat *.m3d);;All Files (*)", + ) + if file_path: + self.lut3d_path_widget.setText(file_path) + + def init_color_group(self): + group = QtWidgets.QGroupBox(t("Color")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) - def init_color_info(self): - self.last_row += 1 self.color_primaries_widget = QtWidgets.QComboBox() self.color_primaries_widget.addItem(t("Unspecified")) self.color_primaries_widget.addItems(ffmpeg_valid_color_primaries) @@ -315,126 +529,228 @@ def init_color_info(self): self.color_space_widget.addItems(ffmpeg_valid_color_space) self.color_space_widget.currentIndexChanged.connect(self.page_update) - self.add_row_label(t("Color Formats"), self.last_row) - self.layout.addWidget(QtWidgets.QLabel(t("Color Primaries")), self.last_row, 1, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.color_primaries_widget, self.last_row, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Color Transfer")), self.last_row, 3, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.color_transfer_widget, self.last_row, 4) - self.layout.addWidget(QtWidgets.QLabel(t("Color Space")), self.last_row, 5, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.color_space_widget, self.last_row, 6) + gl.addWidget(QtWidgets.QLabel(t("Color Primaries")), 0, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.color_primaries_widget, 0, 1) + gl.addWidget(QtWidgets.QLabel(t("Color Transfer")), 0, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.color_transfer_widget, 0, 3) + gl.addWidget(QtWidgets.QLabel(t("Color Space")), 0, 4, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.color_space_widget, 0, 5) + + self.setup_group(group, rows=1) + return group + + def init_output_group(self): + group = QtWidgets.QGroupBox(t("Output")) + gl = QtWidgets.QGridLayout(group) + self.setup_grid(gl) - def init_vbv(self): - self.last_row += 1 self.maxrate_widget = QtWidgets.QLineEdit() - # self.maxrate_widget.setPlaceholderText("3000") self.maxrate_widget.setValidator(self.only_int) self.maxrate_widget.textChanged.connect(self.page_update) self.bufsize_widget = QtWidgets.QLineEdit() - # self.bufsize_widget.setPlaceholderText("3000") self.bufsize_widget.setValidator(self.only_int) self.bufsize_widget.textChanged.connect(self.page_update) - # self.vbv_checkbox = QtWidgets.QCheckBox(t("Enable VBV")) - # self.vbv_checkbox.toggled.connect(self.vbv_check_changed) + gl.addWidget(QtWidgets.QLabel(f"{t('Maxrate')} (kbps)"), 0, 0, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.maxrate_widget, 0, 1) + gl.addWidget(QtWidgets.QLabel(f"{t('Bufsize')} (kbps)"), 0, 2, alignment=QtCore.Qt.AlignRight) + gl.addWidget(self.bufsize_widget, 0, 3) + gl.addWidget(QtWidgets.QLabel(t("Both must have values to be enabled")), 0, 4, 1, 2) - self.add_row_label(f"{t('Video Buffering')}\n{t('Verifier')} (VBV)", self.last_row) - self.layout.addWidget( - QtWidgets.QLabel(f"{t('Maxrate')} (kbps)"), self.last_row, 1, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.maxrate_widget, self.last_row, 2) - self.layout.addWidget( - QtWidgets.QLabel(f"{t('Bufsize')} (kbps)"), self.last_row, 3, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.bufsize_widget, self.last_row, 4) - self.layout.addWidget(QtWidgets.QLabel(t("Both must have values to be enabled")), self.last_row, 5, 1, 2) + self.faststart_widget = ToggleSwitch(t("Fast Start (MP4/MOV)")) + self.faststart_widget.setChecked(True) + self.faststart_widget.setToolTip(t("Moves metadata to the beginning of the file for faster streaming start")) + self.faststart_widget.stateChanged.connect(self.page_update) + self.faststart_widget.setVisible(False) + gl.addWidget(self.faststart_widget, 1, 0, 1, 2) - # def vbv_check_changed(self): - # self.bufsize_widget.setEnabled(self.vbv_checkbox.isChecked()) - # self.maxrate_widget.setEnabled(self.vbv_checkbox.isChecked()) - # self.page_update() - - # def init_subtitle_overlay_fix(self): - # self.last_row += 1 - # TODO figure out overlay for subtitles move up - # overlay=y=-140 - # crop=1904:800:6:140 - # (800 + 140) - 1080 == -140 + self.setup_group(group, rows=2) + return group def init_hw_message(self): - self.last_row += 1 - label = QtWidgets.QLabel("ʘ " + t("Not supported by rigaya's hardware encoders")) + label = QtWidgets.QLabel( + "ʘ " + + t( + "Not supported by rigaya's hardware encoders" + " (Video Speed, Reverse Video, Deflicker, Vibrance, Color Temperature, Colorbalance)" + ) + ) if self.app.fastflix.config.theme == "onyx": label.setStyleSheet(get_onyx_label_style(muted=True)) + self.inner_layout.addWidget(label) - self.layout.addWidget(label, self.last_row, 0, 1, 2) - - def init_titles(self): - self.video_title = QtWidgets.QLineEdit() - self.video_title.setPlaceholderText(t("Video Title")) - self.video_title.textChanged.connect(self.page_update) - - self.video_track_title = QtWidgets.QLineEdit() - self.video_track_title.setPlaceholderText(t("Video Track Title") + " ʘ") - self.video_track_title.textChanged.connect(self.page_update) - - self.layout.addWidget(QtWidgets.QLabel(t("Video Title")), self.last_row, 3, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.video_title, self.last_row, 4) - self.layout.addWidget( - QtWidgets.QLabel(t("Video Track Title") + " ʘ"), self.last_row, 5, alignment=QtCore.Qt.AlignRight - ) - self.layout.addWidget(self.video_track_title, self.last_row, 6) - - def init_custom_filters(self): - self.last_row += 1 - - self.first_filters = QtWidgets.QLineEdit() - self.first_filters.textChanged.connect(self.page_update) + def fps_update(self, myself, widget): + widget.setDisabled(myself.isChecked()) + self.page_update() - self.second_filters = QtWidgets.QLineEdit() - self.second_filters.textChanged.connect(self.page_update) + def on_video_speed_changed(self): + if not self.app.fastflix.config.suppress_video_speed_warning: + current_speed = video_speeds[self.video_speed_widget.currentText()] + if current_speed != 1: + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setWindowTitle(t("Video Speed Warning")) + msg.setText(t("Warning: Audio will not be modified when changing video speed.")) + cb = QtWidgets.QCheckBox(t("Don't show this warning again")) + msg.setCheckBox(cb) + msg.addButton(t("OK"), QtWidgets.QMessageBox.AcceptRole) + msg.exec() + if cb.isChecked(): + self.app.fastflix.config.suppress_video_speed_warning = True + self.app.fastflix.config.save() + self.page_update() - self.add_row_label(t("Custom Filters"), self.last_row) - self.layout.addWidget(QtWidgets.QLabel(t("First Pass")), self.last_row, 1, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.first_filters, self.last_row, 2, 1, 2) - self.layout.addWidget(QtWidgets.QLabel(t("Second Pass")), self.last_row, 4, alignment=QtCore.Qt.AlignRight) - self.layout.addWidget(self.second_filters, self.last_row, 5, 1, 2) + def on_reverse_video_changed(self): + if self.reverse_video_widget.isChecked() and not self.app.fastflix.config.suppress_reverse_video_warning: + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setWindowTitle(t("Reverse Video Warning")) + msg.setText( + t("The reverse filter buffers all video frames in memory.") + + " " + + t("This may require significant RAM for long or high-resolution videos.") + + "\n\n" + + t("Audio on converted (non-copy) tracks will also be reversed.") + ) + cb = QtWidgets.QCheckBox(t("Don't show this warning again")) + msg.setCheckBox(cb) + msg.addButton(t("OK"), QtWidgets.QMessageBox.AcceptRole) + msg.exec() + if cb.isChecked(): + self.app.fastflix.config.suppress_reverse_video_warning = True + self.app.fastflix.config.save() + self.page_update() def update_settings(self): if self.updating or not self.app.fastflix.current_video: return False self.updating = True self.app.fastflix.current_video.video_settings.video_speed = video_speeds[self.video_speed_widget.currentText()] + self.app.fastflix.current_video.video_settings.reverse_video = self.reverse_video_widget.isChecked() self.app.fastflix.current_video.video_settings.deblock = non(self.deblock_widget.currentText()) self.app.fastflix.current_video.video_settings.deblock_size = int(self.deblock_size_widget.currentText()) self.app.fastflix.current_video.video_settings.tone_map = self.tone_map_widget.currentText() self.app.fastflix.current_video.video_settings.vsync = non(self.vsync_widget.currentText()) - try: - if self.brightness_widget.text().strip() != "": + if self.brightness_widget.text().strip(): + try: self.app.fastflix.current_video.video_settings.brightness = str(float(self.brightness_widget.text())) - except ValueError: - logger.warning("Invalid brightness value") + except ValueError: + logger.warning("Invalid brightness value") + else: + self.app.fastflix.current_video.video_settings.brightness = None - try: - if self.saturation_widget.text().strip() != "": + if self.saturation_widget.text().strip(): + try: self.app.fastflix.current_video.video_settings.saturation = str(float(self.saturation_widget.text())) - except ValueError: - logger.warning("Invalid saturation value") + except ValueError: + logger.warning("Invalid saturation value") + else: + self.app.fastflix.current_video.video_settings.saturation = None - try: - if self.contrast_widget.text().strip() != "": + if self.contrast_widget.text().strip(): + try: self.app.fastflix.current_video.video_settings.contrast = str(float(self.contrast_widget.text())) - except ValueError: - logger.warning("Invalid contrast value") + except ValueError: + logger.warning("Invalid contrast value") + else: + self.app.fastflix.current_video.video_settings.contrast = None + + if self.gamma_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.gamma = str(float(self.gamma_widget.text())) + except ValueError: + logger.warning("Invalid gamma value") + else: + self.app.fastflix.current_video.video_settings.gamma = None + + if self.hue_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.hue = str(float(self.hue_widget.text())) + except ValueError: + logger.warning("Invalid hue value") + else: + self.app.fastflix.current_video.video_settings.hue = None + + if self.sharpen_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.sharpen = str(float(self.sharpen_widget.text())) + except ValueError: + logger.warning("Invalid sharpen value") + else: + self.app.fastflix.current_video.video_settings.sharpen = None + + if self.vibrance_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.vibrance = str(float(self.vibrance_widget.text())) + except ValueError: + logger.warning("Invalid vibrance value") + else: + self.app.fastflix.current_video.video_settings.vibrance = None + + if self.color_temperature_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.color_temperature = str( + int(self.color_temperature_widget.text()) + ) + except ValueError: + logger.warning("Invalid color temperature value") + else: + self.app.fastflix.current_video.video_settings.color_temperature = None + + if self.curves_preset_widget.currentIndex() == 0: + self.app.fastflix.current_video.video_settings.curves_preset = None + else: + self.app.fastflix.current_video.video_settings.curves_preset = self.curves_preset_widget.currentText() + + if self.colorbalance_widget.currentIndex() == 0: + self.app.fastflix.current_video.video_settings.colorbalance = None + else: + preset_name = self.colorbalance_widget.currentText() + self.app.fastflix.current_video.video_settings.colorbalance = colorbalance_presets[preset_name] + + if self.unsharp_widget.currentIndex() == 0: + self.app.fastflix.current_video.video_settings.unsharp = None + else: + self.app.fastflix.current_video.video_settings.unsharp = unsharp_presets[self.unsharp_widget.currentText()] + + if self.deflicker_widget.currentIndex() == 0: + self.app.fastflix.current_video.video_settings.deflicker = None + else: + self.app.fastflix.current_video.video_settings.deflicker = deflicker_presets[ + self.deflicker_widget.currentText() + ] + + if self.pad_aspect_widget.currentIndex() == 0: + self.app.fastflix.current_video.video_settings.pad_aspect = None + else: + self.app.fastflix.current_video.video_settings.pad_aspect = self.pad_aspect_widget.currentText() + self.app.fastflix.current_video.video_settings.pad_color = self.pad_color_widget.text().strip() or "black" + + self.app.fastflix.current_video.video_settings.lut3d_path = self.lut3d_path_widget.text().strip() or None + + self.app.fastflix.current_video.video_settings.faststart = self.faststart_widget.isChecked() + + if self.gop_length_widget.text().strip(): + try: + self.app.fastflix.current_video.video_settings.gop_length = int(self.gop_length_widget.text()) + except ValueError: + logger.warning("Invalid GOP length value") + else: + self.app.fastflix.current_video.video_settings.gop_length = None # self.app.fastflix.current_video.video_settings.first_pass_filters = self.first_filters.text() or None # self.app.fastflix.current_video.video_settings.second_filters = self.second_filters.text() or None if not self.incoming_same_as_source.isChecked(): self.app.fastflix.current_video.video_settings.source_fps = self.incoming_fps_widget.text() + else: + self.app.fastflix.current_video.video_settings.source_fps = None if not self.outgoing_same_as_source.isChecked(): self.app.fastflix.current_video.video_settings.output_fps = self.outgoing_fps_widget.text() + else: + self.app.fastflix.current_video.video_settings.output_fps = None if self.denoise_type_widget.currentIndex() == 0: self.app.fastflix.current_video.video_settings.denoise = None @@ -443,6 +759,10 @@ def update_settings(self): self.denoise_type_widget.currentText() ][self.denoise_strength_widget.currentText()] + self.app.fastflix.current_video.video_settings.deinterlace_filter = deinterlace_presets[ + self.deinterlace_method_widget.currentText() + ][self.deinterlace_mode_widget.currentText()] + if self.color_primaries_widget.currentIndex() == 0: self.app.fastflix.current_video.video_settings.color_primaries = None else: @@ -465,8 +785,14 @@ def update_settings(self): self.app.fastflix.current_video.video_settings.maxrate = None self.app.fastflix.current_video.video_settings.bufsize = None + self.update_faststart_visibility() self.updating = False + def get_deinterlace_filter(self): + return deinterlace_presets[self.deinterlace_method_widget.currentText()][ + self.deinterlace_mode_widget.currentText() + ] + def get_settings(self): denoise = None if self.denoise_type_widget.currentIndex() != 0: @@ -501,8 +827,68 @@ def get_settings(self): except ValueError: logger.warning("Invalid brightness value") + gamma = None + if self.gamma_widget.text().strip() != "": + try: + gamma = str(float(self.gamma_widget.text())) + except ValueError: + logger.warning("Invalid gamma value") + + hue = None + if self.hue_widget.text().strip() != "": + try: + hue = str(float(self.hue_widget.text())) + except ValueError: + logger.warning("Invalid hue value") + + sharpen = None + if self.sharpen_widget.text().strip() != "": + try: + sharpen = str(float(self.sharpen_widget.text())) + except ValueError: + logger.warning("Invalid sharpen value") + + gop_length = None + if self.gop_length_widget.text().strip(): + try: + gop_length = int(self.gop_length_widget.text()) + except ValueError: + logger.warning("Invalid GOP length value") + + vibrance = None + if self.vibrance_widget.text().strip(): + try: + vibrance = str(float(self.vibrance_widget.text())) + except ValueError: + logger.warning("Invalid vibrance value") + + color_temperature = None + if self.color_temperature_widget.text().strip(): + try: + color_temperature = str(int(self.color_temperature_widget.text())) + except ValueError: + logger.warning("Invalid color temperature value") + + curves_preset = None + if self.curves_preset_widget.currentIndex() != 0: + curves_preset = self.curves_preset_widget.currentText() + + colorbalance = None + if self.colorbalance_widget.currentIndex() != 0: + preset_name = self.colorbalance_widget.currentText() + colorbalance = colorbalance_presets[preset_name] + + unsharp = None + if self.unsharp_widget.currentIndex() != 0: + unsharp = unsharp_presets[self.unsharp_widget.currentText()] + + deflicker = None + if self.deflicker_widget.currentIndex() != 0: + deflicker = deflicker_presets[self.deflicker_widget.currentText()] + return AdvancedOptions( video_speed=video_speeds[self.video_speed_widget.currentText()], + reverse_video=self.reverse_video_widget.isChecked(), deblock=non(self.deblock_widget.currentText()), deblock_size=int(self.deblock_size_widget.currentText()), tone_map=self.tone_map_widget.currentText(), @@ -510,6 +896,26 @@ def get_settings(self): brightness=brightness, saturation=saturation, contrast=contrast, + gamma=gamma, + hue=hue, + sharpen=sharpen, + vibrance=vibrance, + color_temperature=color_temperature, + curves_preset=curves_preset, + curves_preset_index=self.curves_preset_widget.currentIndex(), + colorbalance=colorbalance, + colorbalance_index=self.colorbalance_widget.currentIndex(), + unsharp=unsharp, + unsharp_index=self.unsharp_widget.currentIndex(), + deflicker=deflicker, + deflicker_index=self.deflicker_widget.currentIndex(), + pad_aspect=(None if self.pad_aspect_widget.currentIndex() == 0 else self.pad_aspect_widget.currentText()), + pad_aspect_index=self.pad_aspect_widget.currentIndex(), + pad_color=self.pad_color_widget.text().strip() or "black", + lut3d_path=self.lut3d_path_widget.text().strip() or None, + faststart=self.faststart_widget.isChecked(), + deinterlace=self.main.widgets.deinterlace.isChecked(), + gop_length=gop_length, maxrate=maxrate, bufsize=bufsize, source_fps=(None if self.incoming_same_as_source.isChecked() else self.incoming_fps_widget.text()), @@ -526,8 +932,8 @@ def get_settings(self): denoise=denoise, denoise_type_index=self.denoise_type_widget.currentIndex(), denoise_strength_index=self.denoise_strength_widget.currentIndex(), - # first_pass_filters=self.first_filters.text() or None, - # second_pass_filters=self.second_filters.text() or None, + deinterlace_method_index=self.deinterlace_method_widget.currentIndex(), + deinterlace_mode_index=self.deinterlace_mode_widget.currentIndex(), ) def hdr_settings(self): @@ -576,17 +982,89 @@ def hdr_settings(self): self.color_primaries_widget.setCurrentIndex(0) def page_update(self, build_thumbnail=False): + self.update_faststart_visibility() self.main.page_update(build_thumbnail=build_thumbnail) + def update_faststart_visibility(self): + if not hasattr(self, "faststart_widget"): + return + ext = "" + # Use resolve_output_extension() which handles "Source" → actual extension + try: + result = self.main.resolve_output_extension() + if isinstance(result, str): + ext = result + except Exception: + pass + # Fall back to reading combo directly (e.g. in tests with mock main) + if not ext: + try: + combo_text = self.main.widgets.output_type_combo.currentText().lower() + if isinstance(combo_text, str) and combo_text.startswith("."): + ext = combo_text + except Exception: + pass + # Fall back to output_path if available + if not ext and self.app.fastflix.current_video and self.app.fastflix.current_video.video_settings.output_path: + ext = self.app.fastflix.current_video.video_settings.output_path.suffix.lower() + self.faststart_widget.setVisible(ext in (".mp4", ".mov", ".m4v")) + def reset(self, settings: VideoSettings = None): if settings: self.video_speed_widget.setCurrentText(get_key(video_speeds, settings.video_speed)) + self.reverse_video_widget.setChecked(settings.reverse_video) self.brightness_widget.setText(settings.brightness or "") self.saturation_widget.setText(settings.saturation or "") self.contrast_widget.setText(settings.contrast or "") + self.gamma_widget.setText(settings.gamma or "") + self.hue_widget.setText(settings.hue or "") + self.sharpen_widget.setText(settings.sharpen or "") + self.vibrance_widget.setText(settings.vibrance or "") + self.color_temperature_widget.setText(settings.color_temperature or "") + if settings.curves_preset: + self.curves_preset_widget.setCurrentText(settings.curves_preset) + else: + self.curves_preset_widget.setCurrentIndex(0) + if settings.colorbalance: + for name, value in colorbalance_presets.items(): + if settings.colorbalance == value: + self.colorbalance_widget.setCurrentText(name) + break + else: + self.colorbalance_widget.setCurrentIndex(0) + else: + self.colorbalance_widget.setCurrentIndex(0) + if settings.unsharp: + for name, value in unsharp_presets.items(): + if settings.unsharp == value: + self.unsharp_widget.setCurrentText(name) + break + else: + self.unsharp_widget.setCurrentIndex(0) + else: + self.unsharp_widget.setCurrentIndex(0) + if settings.deflicker: + for name, value in deflicker_presets.items(): + if settings.deflicker == value: + self.deflicker_widget.setCurrentText(name) + break + else: + self.deflicker_widget.setCurrentIndex(0) + else: + self.deflicker_widget.setCurrentIndex(0) + if settings.pad_aspect: + self.pad_aspect_widget.setCurrentText(settings.pad_aspect) + else: + self.pad_aspect_widget.setCurrentIndex(0) + self.pad_color_widget.setText(settings.pad_color if settings.pad_color != "black" else "") + self.lut3d_path_widget.setText(settings.lut3d_path or "") + self.gop_length_widget.setText(str(settings.gop_length) if settings.gop_length else "") + self.faststart_widget.setChecked(settings.faststart if hasattr(settings, "faststart") else True) if settings.deblock: self.deblock_widget.setCurrentText(settings.deblock) + else: + self.deblock_widget.setCurrentIndex(0) self.deblock_size_widget.setCurrentText(str(settings.deblock_size)) self.tone_map_widget.setCurrentText(settings.tone_map) @@ -610,6 +1088,20 @@ def reset(self, settings: VideoSettings = None): if settings.denoise == value: self.denoise_type_widget.setCurrentText(denoise_type) self.denoise_strength_widget.setCurrentText(preset_name) + else: + self.denoise_type_widget.setCurrentIndex(0) + self.denoise_strength_widget.setCurrentIndex(0) + + if settings.deinterlace_filter: + for method, modes in deinterlace_presets.items(): + for mode_name, value in modes.items(): + if settings.deinterlace_filter == value: + self.deinterlace_method_widget.setCurrentText(method) + self.deinterlace_mode_widget.setCurrentText(mode_name) + else: + self.deinterlace_method_widget.setCurrentIndex(0) + self.deinterlace_mode_widget.setCurrentIndex(0) + if settings.vsync: self.vsync_widget.setCurrentText(settings.vsync) else: @@ -639,14 +1131,19 @@ def reset(self, settings: VideoSettings = None): if settings.video_title: self.video_title.setText(settings.video_title) + else: + self.video_title.setText("") if settings.video_track_title: self.video_track_title.setText(settings.video_track_title) + else: + self.video_track_title.setText("") else: self.video_speed_widget.setCurrentIndex( list(video_speeds.values()).index(self.app.fastflix.config.advanced_opt("video_speed")) ) + self.reverse_video_widget.setChecked(self.app.fastflix.config.advanced_opt("reverse_video")) deblock = self.app.fastflix.config.advanced_opt("deblock") if not deblock: @@ -673,6 +1170,13 @@ def reset(self, settings: VideoSettings = None): self.app.fastflix.config.advanced_opt("denoise_strength_index") ) + self.deinterlace_method_widget.setCurrentIndex( + self.app.fastflix.config.advanced_opt("deinterlace_method_index") + ) + self.deinterlace_mode_widget.setCurrentIndex( + self.app.fastflix.config.advanced_opt("deinterlace_mode_index") + ) + vsync_value = self.app.fastflix.config.advanced_opt("vsync") self.vsync_widget.setCurrentIndex(0 if not vsync_value else (vsync.index(vsync_value) + 1)) @@ -687,10 +1191,27 @@ def reset(self, settings: VideoSettings = None): self.brightness_widget.setText(self.app.fastflix.config.advanced_opt("brightness") or "") self.saturation_widget.setText(self.app.fastflix.config.advanced_opt("saturation") or "") self.contrast_widget.setText(self.app.fastflix.config.advanced_opt("contrast") or "") + self.gamma_widget.setText(self.app.fastflix.config.advanced_opt("gamma") or "") + self.hue_widget.setText(self.app.fastflix.config.advanced_opt("hue") or "") + self.sharpen_widget.setText(self.app.fastflix.config.advanced_opt("sharpen") or "") + self.vibrance_widget.setText(self.app.fastflix.config.advanced_opt("vibrance") or "") + self.color_temperature_widget.setText(self.app.fastflix.config.advanced_opt("color_temperature") or "") + self.curves_preset_widget.setCurrentIndex(self.app.fastflix.config.advanced_opt("curves_preset_index") or 0) + self.colorbalance_widget.setCurrentIndex(self.app.fastflix.config.advanced_opt("colorbalance_index") or 0) + self.unsharp_widget.setCurrentIndex(self.app.fastflix.config.advanced_opt("unsharp_index") or 0) + self.deflicker_widget.setCurrentIndex(self.app.fastflix.config.advanced_opt("deflicker_index") or 0) + self.pad_aspect_widget.setCurrentIndex(self.app.fastflix.config.advanced_opt("pad_aspect_index") or 0) + pad_color_val = self.app.fastflix.config.advanced_opt("pad_color") + self.pad_color_widget.setText(pad_color_val if pad_color_val and pad_color_val != "black" else "") + self.lut3d_path_widget.setText(self.app.fastflix.config.advanced_opt("lut3d_path") or "") + gop_val = self.app.fastflix.config.advanced_opt("gop_length") + self.gop_length_widget.setText(str(gop_val) if gop_val else "") + faststart_val = self.app.fastflix.config.advanced_opt("faststart") + self.faststart_widget.setChecked(faststart_val if faststart_val is not None else True) self.hdr_settings() - # self.video_title.setText("") - # self.video_track_title.setText("") + self.video_title.setText("") + self.video_track_title.setText("") # Set the frame rate if self.app.fastflix.current_video: @@ -758,6 +1279,8 @@ def new_source(self): if video_speed := advanced_options.video_speed: self.video_speed_widget.setCurrentText(get_key(video_speeds, video_speed)) + self.reverse_video_widget.setChecked(advanced_options.reverse_video) + if deblock := advanced_options.deblock: self.deblock_widget.setCurrentText(deblock) @@ -779,6 +1302,22 @@ def new_source(self): if contrast := advanced_options.contrast: self.contrast_widget.setText(contrast) + if gamma := advanced_options.gamma: + self.gamma_widget.setText(gamma) + + if hue := advanced_options.hue: + self.hue_widget.setText(hue) + + if sharpen := advanced_options.sharpen: + self.sharpen_widget.setText(sharpen) + + if gop_length := advanced_options.gop_length: + self.gop_length_widget.setText(str(gop_length)) + + self.faststart_widget.setChecked(advanced_options.faststart) + + self.main.widgets.deinterlace.setChecked(advanced_options.deinterlace) + if maxrate := advanced_options.maxrate: self.maxrate_widget.setText(str(maxrate)) @@ -797,7 +1336,12 @@ def new_source(self): else: self.outgoing_same_as_source.setChecked(True) - if denoise_type_index := advanced_options.denoise_type_index: + denoise_type_index = advanced_options.denoise_type_index + if denoise_type_index is not None: self.denoise_type_widget.setCurrentIndex(denoise_type_index) - if denoise_strength_index := advanced_options.denoise_strength_index: + denoise_strength_index = advanced_options.denoise_strength_index + if denoise_strength_index is not None: self.denoise_strength_widget.setCurrentIndex(denoise_strength_index) + + self.deinterlace_method_widget.setCurrentIndex(advanced_options.deinterlace_method_index) + self.deinterlace_mode_widget.setCurrentIndex(advanced_options.deinterlace_mode_index) diff --git a/fastflix/widgets/panels/audio_panel.py b/fastflix/widgets/panels/audio_panel.py index e867c487..e6112cc8 100644 --- a/fastflix/widgets/panels/audio_panel.py +++ b/fastflix/widgets/panels/audio_panel.py @@ -19,6 +19,7 @@ from fastflix.widgets.panels.abstract_list import FlixList from fastflix.audio_processing import apply_audio_filters from fastflix.widgets.windows.audio_conversion import AudioConversion +from fastflix.widgets.toggle_switch import ToggleSwitch from fastflix.widgets.windows.disposition import Disposition language_list = [v.name for v in iter_langs() if v.pt2b and v.pt1] + ["Undefined"] @@ -52,6 +53,7 @@ "vorbis": "Vorbis", "libvorbis": "Vorbis", "mp3": "MP3", + "libfdk_aac": "FDK AAC", "libmp3lame": "MP3", "pcm_s16le": "PCM", "pcm_s24le": "PCM", @@ -118,7 +120,7 @@ def __init__( audio_info=QtWidgets.QLabel(audio_track.friendly_info), up_button=QtWidgets.QPushButton(QtGui.QIcon(get_icon("up-arrow", self.app.fastflix.config.theme)), ""), down_button=QtWidgets.QPushButton(QtGui.QIcon(get_icon("down-arrow", self.app.fastflix.config.theme)), ""), - enable_check=QtWidgets.QCheckBox(t("Enabled")), + enable_check=ToggleSwitch(t("Enabled")), dup_button=QtWidgets.QPushButton(QtGui.QIcon(get_icon("onyx-copy", self.app.fastflix.config.theme)), ""), delete_button=QtWidgets.QPushButton(QtGui.QIcon(get_icon("black-x", self.app.fastflix.config.theme)), ""), language=QtWidgets.QComboBox(), @@ -324,13 +326,16 @@ def set_outdex(self, outdex): self.widgets.track_number.setText(f"{audio_track.index}:{audio_track.outdex}") def close(self) -> bool: - del self.widgets + if hasattr(self, "widgets"): + del self.widgets return super().close() - def update_track(self, conversion=None, bitrate=None, downmix=None, title=None): + def update_track(self, conversion=None, bitrate=None, downmix=None, title=None, conversion_profile=None): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if conversion: audio_track.conversion_codec = conversion + if conversion_profile: + audio_track.conversion_profile = conversion_profile if bitrate: audio_track.conversion_bitrate = bitrate if downmix: @@ -344,7 +349,10 @@ def check_conversion_button(self): audio_track: AudioTrack = self.app.fastflix.current_video.audio_tracks[self.index] if audio_track.conversion_codec: self.widgets.conversion.setStyleSheet(get_onyx_disposition_style(enabled=True)) - self.widgets.conversion.setText(t("Conversion") + f": {audio_track.conversion_codec}") + profile_display = {"aac_he": " (HE-AAC)", "aac_he_v2": " (HE-AAC v2)"}.get( + audio_track.conversion_profile, "" + ) + self.widgets.conversion.setText(t("Conversion") + f": {audio_track.conversion_codec}{profile_display}") else: self.widgets.conversion.setStyleSheet(get_onyx_disposition_style(enabled=False)) self.widgets.conversion.setText(t("Conversion")) @@ -476,6 +484,7 @@ def gen_track( enabled=True, downmix=None, conversion=None, + conversion_profile=None, bitrate=None, title_mode=None, custom_title=None, @@ -510,6 +519,7 @@ def gen_track( friendly_info=track_info, downmix=downmix, conversion_codec=conversion, + conversion_profile=conversion_profile, conversion_bitrate=bitrate, dispositions={k: bool(v) for k, v in audio_track.disposition.items()}, ) @@ -577,6 +587,7 @@ def gen_track( self.tracks[track_pos].update_track( downmix=track[1].downmix, conversion=track[1].conversion, + conversion_profile=track[1].conversion_profile, bitrate=track[1].bitrate, title=title, ) @@ -594,6 +605,7 @@ def gen_track( enabled=True, og=False, conversion=track[1].conversion, + conversion_profile=track[1].conversion_profile, bitrate=track[1].bitrate, downmix=track[1].downmix, title_mode=track[1].title_mode, diff --git a/fastflix/widgets/panels/cover_panel.py b/fastflix/widgets/panels/cover_panel.py index 986c5d77..e3bb4065 100644 --- a/fastflix/widgets/panels/cover_panel.py +++ b/fastflix/widgets/panels/cover_panel.py @@ -12,6 +12,7 @@ from fastflix.models.encode import AttachmentTrack from fastflix.models.fastflix_app import FastFlixApp from fastflix.shared import link +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") @@ -43,15 +44,15 @@ def __init__(self, parent, app: FastFlixApp): layout.addWidget(info_label, 10, 0, 1, 9, QtCore.Qt.AlignLeft) poster_options_layout = QtWidgets.QHBoxLayout() - self.cover_passthrough_checkbox = QtWidgets.QCheckBox(t("Copy Cover")) - self.small_cover_passthrough_checkbox = QtWidgets.QCheckBox(t("Copy Small Cover (no preview)")) + self.cover_passthrough_checkbox = ToggleSwitch(t("Copy Cover")) + self.small_cover_passthrough_checkbox = ToggleSwitch(t("Copy Small Cover (no preview)")) poster_options_layout.addWidget(self.cover_passthrough_checkbox) poster_options_layout.addWidget(self.small_cover_passthrough_checkbox) land_options_layout = QtWidgets.QHBoxLayout() - self.cover_land_passthrough_checkbox = QtWidgets.QCheckBox(t("Copy Landscape Cover")) - self.small_cover_land_passthrough_checkbox = QtWidgets.QCheckBox(t("Copy Small Landscape Cover (no preview)")) + self.cover_land_passthrough_checkbox = ToggleSwitch(t("Copy Landscape Cover")) + self.small_cover_land_passthrough_checkbox = ToggleSwitch(t("Copy Small Landscape Cover (no preview)")) land_options_layout.addWidget(self.cover_land_passthrough_checkbox) land_options_layout.addWidget(self.small_cover_land_passthrough_checkbox) @@ -231,6 +232,12 @@ def update_cover_settings(self): subtitle_track.outdex = start_outdex start_outdex += 1 + # Data/attachment tracks use -map and occupy output stream slots + # before -attach streams (FFmpeg places -map before -attach in output) + for data_track in getattr(self.app.fastflix.current_video, "data_tracks", []) or []: + if data_track.enabled: + start_outdex += 1 + attachments: list[AttachmentTrack] = [] for filename in ("cover", "cover_land", "small_cover", "small_cover_land"): diff --git a/fastflix/widgets/panels/data_panel.py b/fastflix/widgets/panels/data_panel.py index 16878595..90c59f96 100644 --- a/fastflix/widgets/panels/data_panel.py +++ b/fastflix/widgets/panels/data_panel.py @@ -12,14 +12,15 @@ from fastflix.shared import no_border, shrink_text_to_fit from fastflix.ui_scale import scaler from fastflix.widgets.panels.abstract_list import FlixList +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") COVER_NAMES = {"cover", "small_cover", "cover_land", "small_cover_land"} # Container support for data/attachment streams -# MKV supports everything; MP4/M4V support timecodes but not font attachments -NO_DATA_EXTENSIONS = {".gif", ".webm", ".webp", ".avif"} +# MKV does not support data streams (attachments like fonts are fine) +NO_DATA_EXTENSIONS = {".gif", ".webm", ".webp", ".avif", ".mkv", ".mka"} NO_ATTACHMENT_EXTENSIONS = {".gif", ".webm", ".webp", ".avif", ".mp4", ".m4v", ".mov", ".ts", ".mts", ".m2ts"} @@ -43,21 +44,26 @@ def __init__(self, app, parent, index, enabled=True, first=False): else: type_badge = t("Data") + self.incompatible = False + self.widgets = Box( track_number=QtWidgets.QLabel(f"{track.index}:{track.outdex}" if enabled else "❌"), info_label=QtWidgets.QLabel(f" {track.friendly_info}"), type_badge=QtWidgets.QLabel(type_badge), + warning_label=QtWidgets.QLabel(""), up_button=QtWidgets.QPushButton( QtGui.QIcon(get_icon("up-arrow", self.parent.app.fastflix.config.theme)), "" ), down_button=QtWidgets.QPushButton( QtGui.QIcon(get_icon("down-arrow", self.parent.app.fastflix.config.theme)), "" ), - enable_check=QtWidgets.QCheckBox(t("Preserve")), + enable_check=ToggleSwitch(t("Preserve")), ) self.widgets.up_button.setStyleSheet(no_border) self.widgets.down_button.setStyleSheet(no_border) + self.widgets.warning_label.setStyleSheet("color: #cc6600; font-size: 11px;") + self.widgets.warning_label.hide() self.widgets.enable_check.setChecked(enabled) self.widgets.enable_check.toggled.connect(self.update_enable) @@ -81,32 +87,66 @@ def __init__(self, app, parent, index, enabled=True, first=False): self.grid.setColumnStretch(2, True) self.grid.addWidget(self.widgets.type_badge, 0, 3) self.grid.addWidget(self.widgets.enable_check, 0, 4) + self.grid.addWidget(self.widgets.warning_label, 1, 1, 1, 4) self.setLayout(self.grid) self.loading = False - def _check_compatibility(self, track: DataTrack): - output_path = self.app.fastflix.current_video.video_settings.output_path - if not output_path: - return - ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "" - ext_with_dot = f".{ext}" + def _check_compatibility(self, track: DataTrack = None): + if track is None: + track = self.app.fastflix.current_video.data_tracks[self.index] + + # Use resolve_output_extension() which handles "Source" → actual extension + try: + ext_with_dot = self.parent.main.resolve_output_extension() + except (AttributeError, RuntimeError): + ext_with_dot = "" + + if not ext_with_dot: + output_path = self.app.fastflix.current_video.video_settings.output_path + if not output_path: + return + ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "" + ext_with_dot = f".{ext}" incompatible = False reason = "" - if track.codec_type == "data" and ext_with_dot in NO_DATA_EXTENSIONS: + # Check if current encoder is a rigaya hardware encoder (NVEncC, QSVEncC, VCEEncC) + is_rigaya = False + try: + encoder_name = self.app.fastflix.current_video.video_settings.video_encoder_settings.name + is_rigaya = "encc" in encoder_name.lower() + except (AttributeError, RuntimeError): + pass + + if is_rigaya and ext_with_dot not in {".mkv", ".mka"}: + incompatible = True + reason = t("Data and attachment streams are not supported by hardware encoders for this output format") + elif track.codec_type == "data" and ext_with_dot in NO_DATA_EXTENSIONS: incompatible = True reason = t("Data streams are not supported in this output format") elif track.codec_type == "attachment" and ext_with_dot in NO_ATTACHMENT_EXTENSIONS: incompatible = True reason = t("Attachment streams are not supported in this output format") - if incompatible: + if incompatible and not self.incompatible: + # Became incompatible — disable and dim + self.incompatible = True self.widgets.enable_check.setChecked(False) self.widgets.enable_check.setEnabled(False) self.widgets.enable_check.setToolTip(reason) + self.widgets.warning_label.setText(f"⚠ {reason}") + self.widgets.warning_label.show() + self.setStyleSheet("QTabWidget#DataTrack { background-color: rgba(0, 0, 0, 30); }") track.enabled = False + elif not incompatible and self.incompatible: + # Was incompatible but now compatible — re-enable + self.incompatible = False + self.widgets.enable_check.setEnabled(True) + self.widgets.enable_check.setToolTip("") + self.widgets.warning_label.hide() + self.setStyleSheet("") def init_move_buttons(self): layout = QtWidgets.QVBoxLayout() @@ -300,6 +340,12 @@ def apply_profile_settings(self, profile): else: self.select_all(True) + def refresh(self): + """Recheck compatibility for all tracks when output format may have changed.""" + for track_widget in self.tracks: + track_widget._check_compatibility() + super().refresh() + def get_settings(self): # Widget state is already written to data_tracks via set_outdex / update_enable pass diff --git a/fastflix/widgets/panels/info_panel.py b/fastflix/widgets/panels/info_panel.py index ee873673..2e8b6e6b 100644 --- a/fastflix/widgets/panels/info_panel.py +++ b/fastflix/widgets/panels/info_panel.py @@ -1,15 +1,271 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import json import logging +from pathlib import Path from box import Box -from PySide6 import QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets +from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.resources import get_icon +from fastflix.ui_scale import scaler +from fastflix.ui_styles import ONYX_COLORS logger = logging.getLogger("fastflix") +COLUMNS_PER_ROW = 3 + +VIDEO_SECTIONS = [ + ( + "General", + [ + ("index", "Index"), + ("codec_name", "Codec"), + ("codec_long_name", "Codec Full Name"), + ("profile", "Profile"), + ("level", "Level"), + ("width", "Resolution"), + ("r_frame_rate", "Frame Rate"), + ("avg_frame_rate", "Avg Frame Rate"), + ("duration", "Duration"), + ("bit_rate", "Bit Rate"), + ("nb_frames", "Frames"), + ], + ), + ( + "Pixel Format", + [ + ("pix_fmt", "Format"), + ("bit_depth", "Bit Depth"), + ("bits_per_raw_sample", "Bits/Raw Sample"), + ("has_b_frames", "B-Frames"), + ("field_order", "Field Order"), + ("chroma_location", "Chroma"), + ("sample_aspect_ratio", "Sample AR"), + ("display_aspect_ratio", "Display AR"), + ], + ), + ( + "Color", + [ + ("color_range", "Range"), + ("color_space", "Space"), + ("color_transfer", "Transfer"), + ("color_primaries", "Primaries"), + ], + ), +] + +AUDIO_SECTIONS = [ + ( + "General", + [ + ("index", "Index"), + ("codec_name", "Codec"), + ("codec_long_name", "Codec Full Name"), + ("profile", "Profile"), + ("sample_rate", "Sample Rate"), + ("channels", "Channels"), + ("channel_layout", "Layout"), + ("sample_fmt", "Sample Fmt"), + ("bit_rate", "Bit Rate"), + ("duration", "Duration"), + ("nb_frames", "Frames"), + ("bits_per_sample", "Bits/Sample"), + ], + ), +] + +SUBTITLE_SECTIONS = [ + ( + "General", + [ + ("index", "Index"), + ("codec_name", "Codec"), + ("codec_long_name", "Codec Full Name"), + ("duration", "Duration"), + ], + ), +] + +DEFAULT_SECTIONS = [ + ( + "General", + [ + ("index", "Index"), + ("codec_name", "Codec"), + ("codec_long_name", "Codec Full Name"), + ("codec_type", "Type"), + ], + ), +] + +SECTION_MAP = { + "video": VIDEO_SECTIONS, + "audio": AUDIO_SECTIONS, + "subtitle": SUBTITLE_SECTIONS, +} + +SPECIAL_KEYS = {"tags", "disposition", "side_data_list", "codec_type"} + + +def format_value(key, value, stream): + if value is None or value == "": + return "" + if key == "bit_rate": + try: + bps = int(value) + if bps >= 1_000_000: + return f"{bps / 1_000_000:.2f} Mbps" + return f"{bps / 1_000:.0f} kbps" + except (ValueError, TypeError): + return str(value) + if key == "sample_rate": + try: + return f"{int(value) / 1_000:.1f} kHz" + except (ValueError, TypeError): + return str(value) + if key in ("r_frame_rate", "avg_frame_rate"): + try: + if "/" in str(value): + num, den = str(value).split("/") + num, den = int(num), int(den) + if den == 0: + return str(value) + return f"{num / den:.3f} fps" + return f"{float(value):.3f} fps" + except (ValueError, TypeError, ZeroDivisionError): + return str(value) + if key == "duration": + try: + secs = float(value) + minutes, secs = divmod(secs, 60) + hours, minutes = divmod(int(minutes), 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:06.3f}" + return f"{int(minutes):02d}:{secs:06.3f}" + except (ValueError, TypeError): + return str(value) + if key == "width": + height = stream.get("height", "") + return f"{value}x{height}" if height else str(value) + if key == "channels": + layout = stream.get("channel_layout", "") + return f"{value} ({layout})" if layout else str(value) + return str(value) + + +def build_section_group(title, fields, is_onyx): + group = QtWidgets.QGroupBox(t(title)) + if is_onyx: + group.setStyleSheet( + f"QGroupBox {{ color: {ONYX_COLORS['text']}; border: 1px solid #666; border-radius: 4px; " + f"margin-top: 8px; padding-top: 4px; }} " + f"QGroupBox::title {{ subcontrol-origin: margin; left: 8px; padding: 0 4px; }}" + ) + grid = QtWidgets.QGridLayout() + grid.setContentsMargins(8, 2, 8, 4) + grid.setHorizontalSpacing(6) + grid.setVerticalSpacing(2) + row = 0 + col = 0 + for label_text, value_text in fields: + key_label = QtWidgets.QLabel(f"{label_text}:") + val_label = QtWidgets.QLabel(str(value_text)) + val_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + grid.addWidget(key_label, row, col * 2) + grid.addWidget(val_label, row, col * 2 + 1) + col += 1 + if col >= COLUMNS_PER_ROW: + col = 0 + row += 1 + # Spread columns evenly across full width + for c in range(COLUMNS_PER_ROW): + grid.setColumnStretch(c * 2 + 1, 1) + group.setLayout(grid) + return group + + +def build_stream_widget(stream, parent, theme): + codec_type = stream.get("codec_type", "") + sections = SECTION_MAP.get(codec_type, DEFAULT_SECTIONS) + is_onyx = theme.lower() in ("dark", "onyx") + + shown_keys = set(SPECIAL_KEYS) + if codec_type == "video": + shown_keys.add("height") + if codec_type == "audio": + shown_keys.add("channel_layout") + + scroll = QtWidgets.QScrollArea(parent) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + + container = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Defined sections + for section_name, field_defs in sections: + fields = [] + for key, label in field_defs: + shown_keys.add(key) + value = stream.get(key) + if value is None or str(value) == "": + continue + fields.append((t(label), format_value(key, value, stream))) + if fields: + layout.addWidget(build_section_group(section_name, fields, is_onyx)) + + # Tags + tags = stream.get("tags", {}) + if tags: + fields = [(k.replace("_", " ").title(), str(v)) for k, v in tags.items()] + layout.addWidget(build_section_group("Tags", fields, is_onyx)) + + # Disposition - only truthy values + disposition = stream.get("disposition", {}) + active = [(k.replace("_", " ").title(), t("Yes")) for k, v in disposition.items() if v] + if active: + layout.addWidget(build_section_group("Disposition", active, is_onyx)) + + # Side Data / HDR + side_data = stream.get("side_data_list", []) + if side_data: + for entry in side_data: + if not isinstance(entry, dict): + continue + side_type = entry.get("side_data_type", t("Unknown")) + fields = [(k.replace("_", " ").title(), str(v)) for k, v in entry.items() if k != "side_data_type"] + if fields: + layout.addWidget(build_section_group(side_type, fields, is_onyx)) + + # Other - remaining keys + other_fields = [] + for key in stream.keys(): + if key in shown_keys: + continue + value = stream.get(key) + if value is None or str(value) == "" or isinstance(value, (dict, list)): + continue + other_fields.append((key.replace("_", " ").title(), format_value(key, value, stream))) + if other_fields: + layout.addWidget(build_section_group("Other", other_fields, is_onyx)) + + layout.addStretch() + container.setLayout(layout) + + # Calculate the natural content size so the scroll area never squishes it + content_size = layout.sizeHint() + container.setMinimumSize(content_size) + + scroll.setWidget(container) + return scroll + class InfoPanel(QtWidgets.QTabWidget): def __init__(self, parent, app: FastFlixApp): @@ -18,6 +274,30 @@ def __init__(self, parent, app: FastFlixApp): self.main = parent.main self.attachments = Box() + save_icon = QtGui.QIcon(get_icon("onyx-save", self.app.fastflix.config.theme)) + + # Expand the tab bar via stylesheet so corner widget buttons fit without clipping + self.tabBar().setStyleSheet(f"QTabBar::tab {{ min-height: {scaler.scale(28)}px; }}") + + corner_widget = QtWidgets.QWidget() + corner_layout = QtWidgets.QHBoxLayout() + corner_layout.setContentsMargins(0, 0, 0, 0) + corner_layout.setSpacing(4) + + self.hdr10_button = QtWidgets.QPushButton(save_icon, t("Download HDR10")) + self.hdr10_button.setToolTip(t("Download HDR10")) + self.hdr10_button.clicked.connect(self.save_hdr10) + self.hdr10_button.hide() + corner_layout.addWidget(self.hdr10_button) + + self.download_button = QtWidgets.QPushButton(save_icon, t("Download JSON")) + self.download_button.setToolTip(t("Download JSON")) + self.download_button.clicked.connect(self.save_json) + corner_layout.addWidget(self.download_button) + + corner_widget.setLayout(corner_layout) + self.setCornerWidget(corner_widget, QtCore.Qt.Corner.TopRightCorner) + def reset(self): for i in range(self.count() - 1, -1, -1): self.removeTab(i) @@ -29,9 +309,75 @@ def reset(self): for x in self.app.fastflix.current_video.streams.values(): all_stream.extend(x) + theme = self.app.fastflix.config.theme + max_content_height = 0 for stream in sorted(all_stream, key=lambda z: z["index"]): - widget = QtWidgets.QTextBrowser(self) - widget.setReadOnly(True) - widget.setDisabled(False) - widget.setText(Box(stream).to_yaml(default_flow_style=False)) + widget = build_stream_widget(stream, self, theme) self.addTab(widget, f"{stream['index']}: {stream['codec_type'].title()} ({stream.get('codec_name', '')})") + inner = widget.widget() + if inner: + max_content_height = max(max_content_height, inner.minimumSizeHint().height()) + + if max_content_height > 0: + tab_bar_height = self.tabBar().sizeHint().height() + self.setMinimumHeight(max_content_height + tab_bar_height + 16) + + video = self.app.fastflix.current_video + if video and (video.master_display or video.cll): + self.hdr10_button.show() + else: + self.hdr10_button.hide() + + def save_hdr10(self): + video = self.app.fastflix.current_video + if not video: + return + + lines = [] + if video.master_display: + md = video.master_display + lines.append(f"master-display=G{md.green}B{md.blue}R{md.red}WP{md.white}L{md.luminance}") + if video.cll: + lines.append(f"max-cll={video.cll}") + + if not lines: + return + + source_name = video.source.stem + filename = QtWidgets.QFileDialog.getSaveFileName( + self, + caption=t("Download HDR10"), + dir=str(Path("~").expanduser() / f"{source_name}_hdr10.txt"), + filter=f"{t('Text Files')} (*.txt)", + ) + if filename and filename[0]: + try: + Path(filename[0]).write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception: + logger.exception("Failed to save HDR10 metadata") + + def save_json(self): + if not self.app.fastflix.current_video: + return + + all_streams = [] + for x in self.app.fastflix.current_video.streams.values(): + all_streams.extend(x) + all_streams.sort(key=lambda z: z["index"]) + + data = {"streams": [Box(s).to_dict() for s in all_streams]} + if self.app.fastflix.current_video.format: + data["format"] = Box(self.app.fastflix.current_video.format).to_dict() + + source_name = self.app.fastflix.current_video.source.stem + filename = QtWidgets.QFileDialog.getSaveFileName( + self, + caption=t("Download JSON"), + dir=str(Path("~").expanduser() / f"{source_name}_info.json"), + filter=f"{t('JSON Files')} (*.json)", + ) + if filename and filename[0]: + try: + Path(filename[0]).write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") + except Exception: + logger.exception("Failed to save JSON") diff --git a/fastflix/widgets/panels/queue_panel.py b/fastflix/widgets/panels/queue_panel.py index 2d7aadb7..5ca8ad0d 100644 --- a/fastflix/widgets/panels/queue_panel.py +++ b/fastflix/widgets/panels/queue_panel.py @@ -24,6 +24,7 @@ from fastflix.exceptions import FastFlixInternalException from fastflix.windows_tools import allow_sleep_mode, prevent_sleep_mode from fastflix.command_runner import BackgroundRunner +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") @@ -46,6 +47,27 @@ after_done_path = Path(user_data_dir("FastFlix", appauthor=False, roaming=True)) / "after_done_logs" +class ElidedLabel(QtWidgets.QLabel): + """A QLabel that elides text with an ellipsis when it doesn't fit.""" + + def __init__(self, text="", parent=None): + super().__init__(text, parent) + self._full_text = text + self.setMinimumWidth(0) + self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + + def setText(self, text): + self._full_text = text + super().setText(text) + self.update() + + def resizeEvent(self, event): + metrics = QtGui.QFontMetrics(self.font()) + elided = metrics.elidedText(self._full_text, QtCore.Qt.ElideRight, self.width()) + super().setText(elided) + super().resizeEvent(event) + + class EncodeItem(QtWidgets.QTabWidget): def __init__(self, parent, video: Video, index, first=False): self.loading = True @@ -78,22 +100,13 @@ def __init__(self, parent, video: Video, index, first=False): for widget in self.widgets.values(): widget.setStyleSheet(no_border) - title = QtWidgets.QLabel( + title_text = ( video.video_settings.video_title if video.video_settings.video_title else video.video_settings.output_path.name ) - title.setFixedWidth(300) - - settings = Box(copy.deepcopy(video.video_settings.model_dump())) - # settings.output_path = str(settings.output_path) - # for i, o in enumerate(video.attachment_tracks): - # if o.file_path: - # o["file_path"] = str(o["file_path"]) - # del settings.conversion_commands - - title.setToolTip(settings.video_encoder_settings.to_yaml()) - del settings + title = ElidedLabel(title_text) + title.setToolTip(f"{t('Source')}: {video.source}\n{t('Output')}: {video.video_settings.output_path}") open_button = QtWidgets.QPushButton( self.parent.app.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_DirOpenIcon), t("Open Directory") @@ -210,7 +223,8 @@ def close(self) -> bool: item.close() self.widgets[widget] = None del self.video - del self.widgets + if hasattr(self, "widgets"): + del self.widgets del self.parent gc.collect() return super().close() @@ -268,7 +282,7 @@ def __init__(self, parent, app: FastFlixApp): self.pause_encode.setFixedWidth(130) self.pause_encode.setToolTip(t("Pause / Resume the current command")) - self.ignore_errors = QtWidgets.QCheckBox(t("Ignore Errors")) + self.ignore_errors = ToggleSwitch(t("Ignore Errors")) self.ignore_errors.setFixedWidth(150) self.after_done_combo = QtWidgets.QComboBox() @@ -392,10 +406,6 @@ def manually_load_queue(self): self.queue_startup_check(filename) def reorder(self, update=True): - if self.app.fastflix.currently_encoding: - # TODO error? - logger.warning("Reorder queue called while encoding") - return super().reorder(update=update) # TODO find better reorder method for i in range(len(self.tracks) - 1, -1, -1): @@ -405,12 +415,22 @@ def reorder(self, update=True): for track in self.tracks: self.app.fastflix.conversion_list.append(track.video) - for track in self.tracks: - track.widgets.up_button.setDisabled(False) - track.widgets.down_button.setDisabled(False) - if self.tracks: - self.tracks[0].widgets.up_button.setDisabled(True) - self.tracks[-1].widgets.down_button.setDisabled(True) + encoding = self.app.fastflix.currently_encoding + if encoding: + for track in self.tracks: + track.widgets.up_button.setDisabled(True) + track.widgets.down_button.setDisabled(True) + track.widgets.reload_button.setDisabled(True) + else: + for track in self.tracks: + track.widgets.up_button.setDisabled(False) + track.widgets.down_button.setDisabled(False) + track.widgets.reload_button.setDisabled(False) + if self.tracks: + self.tracks[0].widgets.up_button.setDisabled(True) + self.tracks[-1].widgets.down_button.setDisabled(True) + + self.load_queue_button.setDisabled(encoding) save_queue_async(self.app.fastflix.conversion_list, self.app.fastflix.queue_path, self.app.fastflix.config) def new_source(self): @@ -441,8 +461,7 @@ def clear_complete(self): self.new_source() def remove_item(self, video, part_of_clear=False): - if self.app.fastflix.currently_encoding: - # TODO error + if video.status.running: return for i, vid in enumerate(self.app.fastflix.conversion_list): @@ -459,6 +478,8 @@ def remove_item(self, video, part_of_clear=False): # Queue is saved by new_source() -> reorder() -> save_queue_async() def reload_from_queue(self, video): + if self.app.fastflix.currently_encoding: + return try: self.main.reload_video_from_queue(video) except FastFlixInternalException: diff --git a/fastflix/widgets/panels/status_panel.py b/fastflix/widgets/panels/status_panel.py index f1f9c71f..ece3fcb0 100644 --- a/fastflix/widgets/panels/status_panel.py +++ b/fastflix/widgets/panels/status_panel.py @@ -16,6 +16,7 @@ from fastflix.models.encode import GifskiSettings from fastflix.models.video import Video from fastflix.shared import time_to_number, timedelta_to_str +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") @@ -39,7 +40,7 @@ def __init__(self, parent, app: FastFlixApp): layout = QtWidgets.QGridLayout() - self.hide_nal = QtWidgets.QCheckBox(t("Hide NAL unit messages")) + self.hide_nal = ToggleSwitch(t("Hide NAL unit messages")) self.hide_nal.setChecked(True) self.eta_label = QtWidgets.QLabel(f"{t('Time Left')}: N/A") diff --git a/fastflix/widgets/panels/subtitle_panel.py b/fastflix/widgets/panels/subtitle_panel.py index 972fa0ae..db12ea48 100644 --- a/fastflix/widgets/panels/subtitle_panel.py +++ b/fastflix/widgets/panels/subtitle_panel.py @@ -18,6 +18,7 @@ from fastflix.ui_styles import get_onyx_disposition_style from fastflix.widgets.background_tasks import ExtractSubtitleSRT from fastflix.widgets.panels.abstract_list import FlixList +from fastflix.widgets.toggle_switch import ToggleSwitch from fastflix.widgets.windows.disposition import Disposition logger = logging.getLogger("fastflix") @@ -79,10 +80,10 @@ def __init__(self, app, parent, index, enabled=True, first=False): down_button=QtWidgets.QPushButton( QtGui.QIcon(get_icon("down-arrow", self.parent.app.fastflix.config.theme)), "" ), - enable_check=QtWidgets.QCheckBox(t("Preserve")), + enable_check=ToggleSwitch(t("Preserve")), disposition=QtWidgets.QPushButton(t("Dispositions")), language=QtWidgets.QComboBox(), - burn_in=QtWidgets.QCheckBox(t("Burn In")), + burn_in=ToggleSwitch(t("Burn In")), ) self.widgets.up_button.setStyleSheet(no_border) @@ -480,10 +481,10 @@ def __init__(self, app, parent, index, enabled=True, first=False): down_button=QtWidgets.QPushButton( QtGui.QIcon(get_icon("down-arrow", self.parent.app.fastflix.config.theme)), "" ), - enable_check=QtWidgets.QCheckBox(t("Preserve")), + enable_check=ToggleSwitch(t("Preserve")), disposition=QtWidgets.QPushButton(t("Dispositions")), language=QtWidgets.QComboBox(), - burn_in=QtWidgets.QCheckBox(t("Burn In")), + burn_in=ToggleSwitch(t("Burn In")), remove_button=QtWidgets.QPushButton(t("Remove")), ) @@ -866,6 +867,28 @@ def apply_profile_settings(self): sub_track.enabled = enabled track.widgets.enable_check.setChecked(enabled) + # Apply disposition overrides from profile + default_mode = self.app.fastflix.config.opt("subtitle_default_disposition", None) + forced_mode = self.app.fastflix.config.opt("subtitle_forced_disposition", None) + if default_mode or forced_mode: + first_default_set = False + first_forced_set = False + for track in self.tracks: + sub_track = self.app.fastflix.current_video.subtitle_tracks[track.index] + if not sub_track.enabled: + continue + if default_mode == "clear": + sub_track.dispositions["default"] = False + elif default_mode == "first": + sub_track.dispositions["default"] = not first_default_set + first_default_set = True + if forced_mode == "clear": + sub_track.dispositions["forced"] = False + elif forced_mode == "first": + sub_track.dispositions["forced"] = not first_forced_set + first_forced_set = True + track.check_dis_button() + if self.app.fastflix.config.opt("subtitle_automatic_burn_in"): # Reset any existing burn-in for track in self.tracks: diff --git a/fastflix/widgets/settings.py b/fastflix/widgets/settings.py index 28393389..a29bc0a1 100644 --- a/fastflix/widgets/settings.py +++ b/fastflix/widgets/settings.py @@ -20,6 +20,7 @@ ) from fastflix.shared import error_message, link, yes_no_message from fastflix.widgets.flow_layout import FlowLayout +from fastflix.widgets.toggle_switch import ToggleSwitch logger = logging.getLogger("fastflix") language_list = [v.name for v in iter_langs() if v.pt2b and v.pt1] @@ -168,61 +169,66 @@ def _build_settings_tab(self): row += 1 # Checkboxes - self.use_sane_audio = QtWidgets.QCheckBox(t("Use Sane Audio Selection (updatable in config file)")) + self.use_sane_audio = ToggleSwitch(t("Use Sane Audio Selection (updatable in config file)")) if self.app.fastflix.config.use_sane_audio: self.use_sane_audio.setChecked(True) layout.addWidget(self.use_sane_audio, row, 0, 1, 2) row += 1 - self.disable_version_check = QtWidgets.QCheckBox(t("Disable update check on startup")) + self.disable_version_check = ToggleSwitch(t("Disable update check on startup")) if self.app.fastflix.config.disable_version_check: self.disable_version_check.setChecked(True) layout.addWidget(self.disable_version_check, row, 0, 1, 2) row += 1 - self.show_complete_message = QtWidgets.QCheckBox(t("Show completion popup message")) + self.show_complete_message = ToggleSwitch(t("Show completion popup message")) self.show_complete_message.setChecked(self.app.fastflix.config.show_complete_message) layout.addWidget(self.show_complete_message, row, 0, 1, 2) row += 1 - self.show_error_message = QtWidgets.QCheckBox(t("Show error popup message")) + self.show_error_message = ToggleSwitch(t("Show error popup message")) self.show_error_message.setChecked(self.app.fastflix.config.show_error_message) layout.addWidget(self.show_error_message, row, 0, 1, 2) row += 1 - self.clean_old_logs_button = QtWidgets.QCheckBox( + self.keep_source_after_encode = ToggleSwitch(t("Keep source loaded after adding to queue")) + self.keep_source_after_encode.setChecked(self.app.fastflix.config.keep_source_after_encode) + layout.addWidget(self.keep_source_after_encode, row, 0, 1, 2) + row += 1 + + self.clean_old_logs_button = ToggleSwitch( t("Remove GUI logs and compress conversion logs older than 30 days at exit") ) self.clean_old_logs_button.setChecked(self.app.fastflix.config.clean_old_logs) layout.addWidget(self.clean_old_logs_button, row, 0, 1, 3) row += 1 - self.disable_deinterlace_button = QtWidgets.QCheckBox(t("Disable interlace check")) + self.disable_deinterlace_button = ToggleSwitch(t("Disable interlace check")) self.disable_deinterlace_button.setChecked(self.app.fastflix.config.disable_deinterlace_check) layout.addWidget(self.disable_deinterlace_button, row, 0, 1, 3) row += 1 - self.suppress_ffmpeg_version_warning = QtWidgets.QCheckBox(t("Suppress FFmpeg version warning on startup")) + self.suppress_ffmpeg_version_warning = ToggleSwitch(t("Suppress FFmpeg version warning on startup")) self.suppress_ffmpeg_version_warning.setChecked(self.app.fastflix.config.suppress_ffmpeg_version_warning) layout.addWidget(self.suppress_ffmpeg_version_warning, row, 0, 1, 3) row += 1 - self.use_keyframes_for_preview = QtWidgets.QCheckBox(t("Use keyframes for preview images")) + self.use_keyframes_for_preview = ToggleSwitch(t("Use keyframes for preview images")) self.use_keyframes_for_preview.setChecked(self.app.fastflix.config.use_keyframes_for_preview) layout.addWidget(self.use_keyframes_for_preview, row, 0, 1, 3) row += 1 - self.sticky_tabs = QtWidgets.QCheckBox(t("Disable Automatic Tab Switching")) + self.sticky_tabs = ToggleSwitch(t("Disable Automatic Tab Switching")) self.sticky_tabs.setChecked(self.app.fastflix.config.sticky_tabs) layout.addWidget(self.sticky_tabs, row, 0, 1, 2) row += 1 - self.auto_detect_subtitles = QtWidgets.QCheckBox(t("Auto-detect external subtitle files")) + self.auto_detect_subtitles = ToggleSwitch(t("Auto-detect external subtitle files")) self.auto_detect_subtitles.setChecked(self.app.fastflix.config.auto_detect_subtitles) layout.addWidget(self.auto_detect_subtitles, row, 0, 1, 3) row += 1 - self.enable_history = QtWidgets.QCheckBox(t("Enable encoding history")) + self.enable_history = ToggleSwitch(t("Enable encoding history")) self.enable_history.setChecked(bool(self.app.fastflix.config.enable_history)) layout.addWidget(self.enable_history, row, 0, 1, 3) row += 1 @@ -253,7 +259,7 @@ def _build_settings_tab(self): row += 1 # Default Output Directory - self.default_output_dir = QtWidgets.QCheckBox(t("Use same output directory as source file")) + self.default_output_dir = ToggleSwitch(t("Use same output directory as source file")) layout.addWidget(self.default_output_dir, row, 0, 1, 2) row += 1 @@ -283,7 +289,7 @@ def out_click(): self.default_output_dir.clicked.connect(out_click) # Default Source Directory - self.default_source_dir = QtWidgets.QCheckBox(t("No Default Source Folder")) + self.default_source_dir = ToggleSwitch(t("No Default Source Folder")) layout.addWidget(self.default_source_dir, row, 0, 1, 2) row += 1 @@ -504,7 +510,7 @@ def _build_audio_encoders_tab(self): self.audio_encoder_checkboxes = {} for encoder_name in all_encoders: - cb = QtWidgets.QCheckBox(encoder_name) + cb = ToggleSwitch(encoder_name) cb.setChecked(encoder_name in sane_set) self.audio_encoder_checkboxes[encoder_name] = cb scroll_layout.addWidget(cb) @@ -778,6 +784,7 @@ def save(self): self.app.fastflix.config.sticky_tabs = self.sticky_tabs.isChecked() self.app.fastflix.config.show_complete_message = self.show_complete_message.isChecked() self.app.fastflix.config.show_error_message = self.show_error_message.isChecked() + self.app.fastflix.config.keep_source_after_encode = self.keep_source_after_encode.isChecked() self.app.fastflix.config.disable_deinterlace_check = self.disable_deinterlace_button.isChecked() self.app.fastflix.config.suppress_ffmpeg_version_warning = self.suppress_ffmpeg_version_warning.isChecked() self.app.fastflix.config.use_keyframes_for_preview = self.use_keyframes_for_preview.isChecked() diff --git a/fastflix/widgets/toggle_switch.py b/fastflix/widgets/toggle_switch.py new file mode 100644 index 00000000..b426ff82 --- /dev/null +++ b/fastflix/widgets/toggle_switch.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""A modern sliding toggle switch widget, drop-in replacement for QCheckBox.""" + +from PySide6 import QtCore, QtGui, QtWidgets + + +class ToggleSwitch(QtWidgets.QCheckBox): + """A sliding toggle switch that subclasses QCheckBox. + + Drop-in replacement — emits the same stateChanged/toggled signals, + works with isChecked()/setChecked(), and supports a text label. + """ + + def __init__(self, text="", parent=None, track_color="#444444", active_color="#4a9eed", knob_color="#ffffff"): + super().__init__(text, parent) + self.track_color = QtGui.QColor(track_color) + self.active_color = QtGui.QColor(active_color) + self.knob_color = QtGui.QColor(knob_color) + + self._track_width = 24 + self._track_height = 12 + self._knob_diameter = 8 + self._knob_margin = 2 + self._label_spacing = 8 + + self._knob_position = 0.0 + self._animation = QtCore.QPropertyAnimation(self, b"knob_position", self) + self._animation.setDuration(150) + self._animation.setEasingCurve(QtCore.QEasingCurve.InOutCubic) + + self.stateChanged.connect(self._animate) + + def _get_knob_position(self): + return self._knob_position + + def _set_knob_position(self, value): + self._knob_position = value + self.update() + + knob_position = QtCore.Property(float, _get_knob_position, _set_knob_position) + + def _animate(self): + self._animation.stop() + self._animation.setStartValue(self._knob_position) + self._animation.setEndValue(1.0 if self.isChecked() else 0.0) + self._animation.start() + + def sizeHint(self): + text_width = 0 + if self.text(): + fm = QtGui.QFontMetrics(self.font()) + text_width = fm.horizontalAdvance(self.text()) + self._label_spacing + return QtCore.QSize( + self._track_width + text_width + 4, + max(self._track_height + 4, fm.height() if self.text() else self._track_height + 4), + ) + + def minimumSizeHint(self): + return self.sizeHint() + + def hitButton(self, pos): + return self.rect().contains(pos) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # Calculate track position (vertically centered) + track_y = (self.height() - self._track_height) // 2 + track_rect = QtCore.QRectF(0, track_y, self._track_width, self._track_height) + track_radius = self._track_height / 2 + + # Interpolate track color + ratio = self._knob_position + r = self.track_color.red() + (self.active_color.red() - self.track_color.red()) * ratio + g = self.track_color.green() + (self.active_color.green() - self.track_color.green()) * ratio + b = self.track_color.blue() + (self.active_color.blue() - self.track_color.blue()) * ratio + current_color = QtGui.QColor(int(r), int(g), int(b)) + + # Draw track + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(current_color) + painter.drawRoundedRect(track_rect, track_radius, track_radius) + + # Draw knob + knob_travel = self._track_width - self._knob_diameter - 2 * self._knob_margin + knob_x = self._knob_margin + self._knob_position * knob_travel + knob_y = track_y + (self._track_height - self._knob_diameter) / 2 + painter.setBrush(self.knob_color) + painter.drawEllipse(QtCore.QRectF(knob_x, knob_y, self._knob_diameter, self._knob_diameter)) + + # Draw label text + if self.text(): + painter.setPen(self.palette().color(QtGui.QPalette.WindowText)) + text_x = self._track_width + self._label_spacing + text_rect = QtCore.QRectF(text_x, 0, self.width() - text_x, self.height()) + painter.drawText(text_rect, QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, self.text()) + + painter.end() diff --git a/fastflix/widgets/windows/audio_conversion.py b/fastflix/widgets/windows/audio_conversion.py index 93d0e8b2..d662d262 100644 --- a/fastflix/widgets/windows/audio_conversion.py +++ b/fastflix/widgets/windows/audio_conversion.py @@ -108,6 +108,24 @@ def __init__(self, app: FastFlixApp, track_index, encoders, audio_track_update): conversion_layout.addWidget(QtWidgets.QLabel(t("Codec"))) conversion_layout.addWidget(self.conversion_codec, 2) + # AAC Profile + + self.aac_profile_label = QtWidgets.QLabel(t("AAC Profile")) + self.aac_profile = QtWidgets.QComboBox() + self.aac_profile.addItems(["AAC-LC", "HE-AAC", "HE-AAC v2"]) + + profile_map = {"aac_he": 1, "aac_he_v2": 2} + self.aac_profile.setCurrentIndex(profile_map.get(self.audio_track.conversion_profile, 0)) + + aac_codecs = ("aac", "libfdk_aac") + show_profile = self.conversion_codec.currentText() in aac_codecs + self.aac_profile_label.setVisible(show_profile) + self.aac_profile.setVisible(show_profile) + + aac_profile_layout = QtWidgets.QHBoxLayout() + aac_profile_layout.addWidget(self.aac_profile_label) + aac_profile_layout.addWidget(self.aac_profile, 2) + # AQ vs Bitrate self.aq = QtWidgets.QComboBox() @@ -191,6 +209,7 @@ def __init__(self, app: FastFlixApp, track_index, encoders, audio_track_update): layout = QtWidgets.QVBoxLayout() layout.addLayout(conversion_layout) + layout.addLayout(aac_profile_layout) layout.addLayout(quality_layout) layout.addLayout(downmix_layout) layout.addLayout(yes_no_layout) @@ -205,13 +224,19 @@ def set_aq(self): self.bitrate.setDisabled(True) def codec_changed(self): - if self.conversion_codec.currentText() in ["libopus"]: + codec = self.conversion_codec.currentText() + if codec in ["libopus"]: self.aq.setCurrentIndex(10) self.aq.setDisabled(True) - # self.bitrate.setEnabled(True) else: self.aq.setEnabled(True) - # self.bitrate.setDisabled(True) + + aac_codecs = ("aac", "libfdk_aac") + show_profile = codec in aac_codecs + self.aac_profile_label.setVisible(show_profile) + self.aac_profile.setVisible(show_profile) + if not show_profile: + self.aac_profile.setCurrentIndex(0) def save(self): if self.conversion_codec.currentIndex() != 0: @@ -219,6 +244,9 @@ def save(self): else: self.audio_track.conversion_codec = "" + profile_values = {0: None, 1: "aac_he", 2: "aac_he_v2"} + self.audio_track.conversion_profile = profile_values.get(self.aac_profile.currentIndex()) + if self.aq.currentIndex() != 10: self.audio_track.conversion_aq = self.aq.currentIndex() self.audio_track.conversion_bitrate = None diff --git a/fastflix/widgets/windows/concat.py b/fastflix/widgets/windows/concat.py index a7e87c40..2c55d40d 100644 --- a/fastflix/widgets/windows/concat.py +++ b/fastflix/widgets/windows/concat.py @@ -4,11 +4,11 @@ import logging import secrets -from PySide6 import QtWidgets, QtGui +from PySide6 import QtCore, QtWidgets, QtGui from PySide6.QtWidgets import QAbstractItemView from fastflix.language import t -from fastflix.flix import probe +from fastflix.flix import probe, clean_file_string from fastflix.shared import yes_no_message, error_message from fastflix.widgets.status_bar import Task @@ -235,8 +235,11 @@ def __init__(self, app, main, items=None): super().__init__(None) self.app = app self.main = main - self.folder_name = str(self.app.fastflix.config.source_directory) or str(Path.home()) + source_dir = self.app.fastflix.config.source_directory + videos_dir = Path.home() / "Videos" + self.folder_name = str(source_dir) if source_dir else str(videos_dir if videos_dir.exists() else Path.home()) self.setWindowTitle(t("Concatenation Builder")) + self.setAcceptDrops(True) self.concat_area = ConcatScroll(self) self.base_folder_label = QtWidgets.QLabel() @@ -245,13 +248,6 @@ def __init__(self, app, main, items=None): folder_button = QtWidgets.QPushButton(t("Open Folder")) folder_button.clicked.connect(self.select_folder) - # manual_layout = QtWidgets.QHBoxLayout() - # manual_text = QtWidgets.QLineEdit() - # manual_button = QtWidgets.QPushButton("+") - # manual_button.clicked.connect(lambda: self.concat_area.table.add_item(manual_text.text())) - # manual_layout.addWidget(manual_text) - # manual_layout.addWidget(manual_button) - save_buttom = QtWidgets.QPushButton(t("Load")) save_buttom.clicked.connect(self.save) @@ -265,13 +261,50 @@ def __init__(self, app, main, items=None): layout.addLayout(top_bar) layout.addWidget(self.concat_area) - layout.addWidget(QtWidgets.QLabel(t("Drag and Drop to reorder - All items need to be same dimensions"))) + layout.addWidget( + QtWidgets.QLabel( + t("Drag and Drop to reorder, or drop a folder to load files - All items need to be same dimensions") + ) + ) self.setLayout(layout) def set_folder_name(self, name): self.base_folder_label.setText(f"{t('Base Folder')}: {name}") + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + if not event.mimeData().hasUrls(): + return event.ignore() + + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + location = Path(clean_file_string(event.mimeData().urls()[0].toLocalFile())) + if not location.is_dir(): + return + + # Defer heavy folder loading so dropEvent returns immediately, + # releasing the Windows drag-drop COM lock (unfreezes Explorer). + QtCore.QTimer.singleShot(0, lambda: self.load_folder(str(location))) + def select_folder(self): + folder_name = QtWidgets.QFileDialog.getExistingDirectory(self, dir=self.folder_name) + if not folder_name: + return + self.load_folder(folder_name) + + def load_folder(self, folder_name): if self.concat_area.table.model.rowCount() > 0: if not yes_no_message( f"{t('There are already items in this list')},\n" @@ -280,9 +313,7 @@ def select_folder(self): "Confirm Change Folder", ): return - folder_name = QtWidgets.QFileDialog.getExistingDirectory(self, dir=self.folder_name) - if not folder_name: - return + self.folder_name = folder_name self.set_folder_name(folder_name) diff --git a/fastflix/widgets/windows/crop_window.py b/fastflix/widgets/windows/crop_window.py index c493dc63..2e4419c7 100644 --- a/fastflix/widgets/windows/crop_window.py +++ b/fastflix/widgets/windows/crop_window.py @@ -640,6 +640,7 @@ def generate_image(self, with_crop=False): return settings = video.video_settings.model_dump() + settings.pop("reverse_video", None) if video.video_settings.video_encoder_settings.pix_fmt == "yuv420p10le" and video.color_space.startswith( "bt2020" @@ -647,6 +648,10 @@ def generate_image(self, with_crop=False): settings["remove_hdr"] = True if not settings.get("color_transfer"): settings["color_transfer"] = video.color_transfer + if not settings.get("color_primaries"): + settings["color_primaries"] = video.color_primaries + if not settings.get("color_space"): + settings["color_space"] = video.color_space if with_crop: # Transform rotated crop values to unrotated space for FFmpeg @@ -669,13 +674,9 @@ def generate_image(self, with_crop=False): settings["vertical_flip"] = self.vertical_flip settings["horizontal_flip"] = self.horizontal_flip - filters = helpers.generate_filters( - enable_opencl=False, - start_filters="select=eq(pict_type\\,I)" - if self.main.app.fastflix.config.use_keyframes_for_preview - else None, - **settings, - ) + start_filters = "select=eq(pict_type\\,I)" if self.main.app.fastflix.config.use_keyframes_for_preview else None + + filters = helpers.generate_filters(enable_opencl=False, start_filters=start_filters, **settings) output = self.main.app.fastflix.config.work_path / f"crop_preview_{secrets.token_hex(16)}.tiff" @@ -692,8 +693,27 @@ def generate_image(self, with_crop=False): thumb_run = run(thumb_command, shell=True, stderr=PIPE, stdout=PIPE) if thumb_run.returncode > 0: - logger.warning(f"Could not generate crop preview: {thumb_run.stdout} |----| {thumb_run.stderr}") - return + stderr_text = thumb_run.stderr.decode(encoding="utf-8", errors="ignore") + if settings.get("remove_hdr") and "no path between colorspaces" in stderr_text: + logger.warning( + "HDR tonemapping failed for crop preview (video color metadata may be incomplete), " + "retrying without HDR conversion" + ) + settings["remove_hdr"] = False + filters = helpers.generate_filters(enable_opencl=False, start_filters=start_filters, **settings) + output = self.main.app.fastflix.config.work_path / f"crop_preview_{secrets.token_hex(16)}.tiff" + thumb_command = generate_thumbnail_command( + config=self.main.app.fastflix.config, + source=self.main.source_material, + output=output, + filters=filters, + start_time=self._get_preview_time(), + input_track=video.video_settings.selected_track, + ) + thumb_run = run(thumb_command, shell=True, stderr=PIPE, stdout=PIPE) + if thumb_run.returncode > 0: + logger.warning(f"Could not generate crop preview: {thumb_run.stdout} |----| {thumb_run.stderr}") + return pixmap = QtGui.QPixmap(str(output)) diff --git a/fastflix/widgets/windows/disposition.py b/fastflix/widgets/windows/disposition.py index d61e847f..14077e21 100644 --- a/fastflix/widgets/windows/disposition.py +++ b/fastflix/widgets/windows/disposition.py @@ -5,6 +5,7 @@ from fastflix.language import t from fastflix.models.fastflix_app import FastFlixApp +from fastflix.widgets.toggle_switch import ToggleSwitch __all__ = ["Disposition"] @@ -38,9 +39,9 @@ def __init__(self, app: FastFlixApp, parent, track_name, track_index, audio=True self.setMinimumWidth(200) - self.forced = QtWidgets.QCheckBox(t("Forced")) + self.forced = ToggleSwitch(t("Forced")) - self.default = QtWidgets.QCheckBox(t("Default")) + self.default = ToggleSwitch(t("Default")) track = self.get_track() self.forced.setChecked(track.dispositions.get("forced", False)) diff --git a/fastflix/widgets/windows/large_preview.py b/fastflix/widgets/windows/large_preview.py index 14c80cce..6f56a209 100644 --- a/fastflix/widgets/windows/large_preview.py +++ b/fastflix/widgets/windows/large_preview.py @@ -58,6 +58,7 @@ def keyPressEvent(self, a0: QtGui.QKeyEvent) -> None: def generate_image(self): settings = self.main.app.fastflix.current_video.video_settings.model_dump() + settings.pop("reverse_video", None) if not self.main.app.fastflix.current_video.video_settings.video_encoder_settings: return diff --git a/fastflix/widgets/windows/profile_window.py b/fastflix/widgets/windows/profile_window.py index 87e3a3df..b39117a6 100644 --- a/fastflix/widgets/windows/profile_window.py +++ b/fastflix/widgets/windows/profile_window.py @@ -14,6 +14,7 @@ from fastflix.models.encode import x265Settings, setting_types from fastflix.models.profiles import AudioMatch, Profile, MatchItem, MatchType, TitleMode, AdvancedOptions from fastflix.shared import error_message +from fastflix.widgets.toggle_switch import ToggleSwitch from fastflix.encoders.common.audio import channel_list language_list = [v.name for v in iter_langs() if v.pt2b and v.pt1] + ["Undefined"] @@ -23,8 +24,24 @@ match_type_eng = [MatchType.ALL, MatchType.FIRST, MatchType.LAST] match_type_locale = [t("All"), t("First"), t("Last")] -match_item_enums = [MatchItem.ALL, MatchItem.TITLE, MatchItem.TRACK, MatchItem.LANGUAGE, MatchItem.CHANNELS] -match_item_locale = [t("All"), t("Title"), t("Track Number"), t("Language"), t("Channels")] +match_item_enums = [ + MatchItem.ALL, + MatchItem.TITLE, + MatchItem.TRACK, + MatchItem.LANGUAGE, + MatchItem.CHANNELS, + MatchItem.CODEC, + MatchItem.CODEC_PROFILE, +] +match_item_locale = [ + t("All"), + t("Title"), + t("Track Number"), + t("Language"), + t("Channels"), + t("Codec"), + t("Codec & Profile"), +] sub_match_item_enums = [MatchItem.ALL, MatchItem.TRACK, MatchItem.LANGUAGE] sub_match_item_locale = [t("All"), t("Track Number"), t("Language")] @@ -57,6 +74,8 @@ def __init__(self, parent_list, app, main, parent, index): QtWidgets.QComboBox(), QtWidgets.QComboBox(), QtWidgets.QComboBox(), + QtWidgets.QLineEdit(""), + QtWidgets.QLineEdit(""), ] self.match_input = self.match_input_boxes[0] self.match_input_boxes[0].setDisabled(True) @@ -67,6 +86,8 @@ def __init__(self, parent_list, app, main, parent, index): self.match_input_boxes[4].addItems( ["none | unknown", "mono", "stereo", "3 | 2.1", "4", "5", "6 | 5.1", "7", "8 | 7.1", "9", "10"] ) + self.match_input_boxes[5].setPlaceholderText(t("e.g. dts, truehd, aac")) + self.match_input_boxes[6].setPlaceholderText(t("e.g. dts:DTS-HD MA")) self.match_input_boxes[2].view().setFixedWidth(self.match_input_boxes[2].minimumSizeHint().width() + 50) self.match_input_boxes[3].view().setFixedWidth(self.match_input_boxes[3].minimumSizeHint().width() + 50) @@ -197,6 +218,8 @@ def get_settings(self): match_input_value = Language(self.match_input.currentText()).pt2b elif match_item_enum == MatchItem.CHANNELS: match_input_value = str(self.match_input.currentIndex()) + elif match_item_enum in (MatchItem.CODEC, MatchItem.CODEC_PROFILE): + match_input_value = self.match_input.text().strip() else: raise Exception("Internal error, what do we do sir?") @@ -337,20 +360,49 @@ def __init__(self, app, parent): self.sub_language.insertSeparator(1) self.sub_language.insertSeparator(3) self.sub_language.setFixedWidth(250) - self.sub_first_only = QtWidgets.QCheckBox(t("Only select first matching Subtitle Track")) + self.sub_first_only = ToggleSwitch(t("Only select first matching Subtitle Track")) self.sub_language.view().setFixedWidth(self.sub_language.minimumSizeHint().width() + 50) - self.sub_burn_in = QtWidgets.QCheckBox(t("Auto Burn-in first forced or default subtitle track")) + self.sub_burn_in = ToggleSwitch(t("Auto Burn-in first forced or default subtitle track")) + + disposition_options = [t("Keep Source"), t("Clear All"), t("Set on First Track")] + + default_disp_label = QtWidgets.QLabel(t("Default flag")) + self.default_disposition = QtWidgets.QComboBox() + self.default_disposition.addItems(disposition_options) + self.default_disposition.setFixedWidth(250) + + forced_disp_label = QtWidgets.QLabel(t("Forced flag")) + self.forced_disposition = QtWidgets.QComboBox() + self.forced_disposition.addItems(disposition_options) + self.forced_disposition.setFixedWidth(250) layout = QtWidgets.QGridLayout() layout.addWidget(sub_language_label, 0, 0) layout.addWidget(self.sub_language, 0, 1) layout.addWidget(self.sub_first_only, 1, 0) layout.addWidget(self.sub_burn_in, 2, 0, 1, 2) - layout.addWidget(QtWidgets.QWidget(), 3, 0, 1, 2) - layout.setRowStretch(3, True) + layout.addWidget(default_disp_label, 3, 0) + layout.addWidget(self.default_disposition, 3, 1) + layout.addWidget(forced_disp_label, 4, 0) + layout.addWidget(self.forced_disposition, 4, 1) + layout.addWidget(QtWidgets.QWidget(), 5, 0, 1, 2) + layout.setRowStretch(5, True) self.setLayout(layout) + def get_default_disposition(self) -> str | None: + idx = self.default_disposition.currentIndex() + return [None, "clear", "first"][idx] + + def get_forced_disposition(self) -> str | None: + idx = self.forced_disposition.currentIndex() + return [None, "clear", "first"][idx] + + def set_disposition_from_profile(self, default_disp: str | None, forced_disp: str | None): + mapping = {None: 0, "clear": 1, "first": 2} + self.default_disposition.setCurrentIndex(mapping.get(default_disp, 0)) + self.forced_disposition.setCurrentIndex(mapping.get(forced_disp, 0)) + class DataSelect(QtWidgets.QWidget): def __init__(self, app, parent): @@ -440,7 +492,7 @@ def __init__(self, main_options): settings = "\n".join(f"{k:<30} {v}" for k, v in main_options.items()) self.label.setText(f"
{settings}
") - self.auto_crop = QtWidgets.QCheckBox(t("Auto Crop")) + self.auto_crop = ToggleSwitch(t("Auto Crop")) layout.addWidget(self.auto_crop) layout.addStretch(1) @@ -520,7 +572,7 @@ def __init__(self, app: FastFlixApp, main, container, *args, **kwargs): remove_hdr=self.main.remove_hdr, resolution_method=self.main.resolution_method(), resolution_custom=self.main.resolution_custom(), - output_type=self.main.widgets.output_type_combo.currentText(), + output_type=self.main.output_type_for_profile(), ) self.tab_area = QtWidgets.QTabWidget() @@ -624,13 +676,15 @@ def save(self): data_passthrough=self.data_select.get_settings(), resolution_method=self.main_settings.resolution_method, resolution_custom=self.main_settings.resolution_custom, - output_type=self.main.widgets.output_type_combo.currentText(), + output_type=self.main.output_type_for_profile(), # subtitle_filters=self.subtitle_select.get_settings(), subtitle_language=sub_lang, subtitle_select=subtitle_enabled, subtitle_automatic_burn_in=self.subtitle_select.sub_burn_in.isChecked(), subtitle_select_preferred_language=subtitle_select_preferred_language, subtitle_select_first_matching=self.subtitle_select.sub_first_only.isChecked(), + subtitle_default_disposition=self.subtitle_select.get_default_disposition(), + subtitle_forced_disposition=self.subtitle_select.get_forced_disposition(), encoder=self.encoder.name, advanced_options=self.advanced_options, ) diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..a4270485 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/cdgriffith/fastflix-installer + +go 1.22 + +require ( + github.com/klauspost/compress v1.17.11 + golang.org/x/sys v0.28.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..7ed56178 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pyproject.toml b/pyproject.toml index 03e6d3ce..f4472903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,9 +50,11 @@ build-backend = "setuptools.build_meta" [dependency-groups] dev = [ + "deepl>=1.29.0", "pre-commit>=4.2.0", "pyinstaller>=6.13.0", "pytest>=9.0", + "pytest-xdist>=3.5", "ruff>=0.14", "types-requests>=2.32", "types-setuptools>=80.9", @@ -63,6 +65,12 @@ dev = [ [tool.setuptools.dynamic] version = { attr = "fastflix.version.__version__" } +[tool.pytest.ini_options] +markers = [ + "local_only: Tests that require local tools (FFmpeg, encoders) and skip on CI", + "e2e: End-to-end encode tests that run real FFmpeg encodes (local only, skip on CI)", +] + [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ diff --git a/scripts/build_distribution.py b/scripts/build_distribution.py new file mode 100644 index 00000000..5fb0f2a8 --- /dev/null +++ b/scripts/build_distribution.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +""" +Build script for FastFlix distribution using embeddable Python. + +Replaces PyInstaller by bundling the official Python embeddable distribution +with pip-installed dependencies. The output is a self-contained directory +that can be launched via the Go launcher. + +Usage: + python scripts/build_distribution.py [--python-version 3.13.1] [--arch amd64] [--output dist/FastFlix] + +Requirements: + - Internet access (downloads Python embeddable distribution and get-pip.py) + - Or: pre-downloaded files in scripts/cache/ +""" + +import argparse +import platform +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from datetime import datetime +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CACHE_DIR = PROJECT_ROOT / "scripts" / "cache" + + +def detect_arch(): + machine = platform.machine().lower() + if machine in ("amd64", "x86_64", "x64"): + return "amd64" + if machine in ("arm64", "aarch64"): + return "arm64" + return "amd64" + + +def download_file(url, dest): + """Download a file with progress indication.""" + print(f" Downloading {url}") + dest.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(url, dest) + print(f" Saved to {dest} ({dest.stat().st_size / 1024 / 1024:.1f} MB)") + + +def get_python_url(version, arch): + """Get the download URL for the Python embeddable distribution.""" + return f"https://www.python.org/ftp/python/{version}/python-{version}-embed-{arch}.zip" + + +def get_pip_url(): + return "https://bootstrap.pypa.io/get-pip.py" + + +def extract_python(python_zip, target_dir): + """Extract the embeddable Python distribution.""" + print(f" Extracting Python to {target_dir}") + target_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(python_zip) as zf: + zf.extractall(target_dir) + + +def configure_pth_file(python_dir): + """Configure the ._pth file to enable site packages and find our lib directory. + + The embeddable distribution uses a ._pth file to restrict imports. + We need to: + 1. Uncomment 'import site' to enable pip/site-packages + 2. Add '../lib' so Python can find our installed packages + """ + pth_files = list(python_dir.glob("python*._pth")) + if not pth_files: + print(" WARNING: No ._pth file found in Python distribution") + return + + pth_file = pth_files[0] + print(f" Configuring {pth_file.name}") + + lines = pth_file.read_text().splitlines() + new_lines = [] + for line in lines: + # Uncomment 'import site' + if line.strip() == "#import site": + new_lines.append("import site") + else: + new_lines.append(line) + + # Add our lib directory + new_lines.append("../lib") + + pth_file.write_text("\n".join(new_lines) + "\n") + + +def bootstrap_pip(python_dir): + """No longer needed — we use the system Python's pip to install into --target. + + Kept as a no-op for the build step numbering. + """ + pass + + +def build_wheel(project_root): + """Build a wheel of FastFlix using the system Python. + + The embeddable Python doesn't have setuptools in its build isolation + environment, so we build the wheel with the system Python first. + """ + wheel_dir = project_root / "dist" / "wheels" + wheel_dir.mkdir(parents=True, exist_ok=True) + + print(" Building FastFlix wheel with system Python...") + subprocess.run( + [sys.executable, "-m", "pip", "wheel", str(project_root), "--wheel-dir", str(wheel_dir), "--no-deps"], + check=True, + ) + + # Find the built wheel + wheels = list(wheel_dir.glob("fastflix-*.whl")) + if not wheels: + print(" ERROR: No wheel built") + sys.exit(1) + return wheels[0] + + +def install_dependencies(python_dir, lib_dir, project_root): + """Install FastFlix and all dependencies into the lib directory. + + Uses the SYSTEM Python's pip (not the embeddable one) to avoid build + isolation issues. The --target flag installs everything into lib_dir. + """ + lib_dir.mkdir(parents=True, exist_ok=True) + + # Build fastflix wheel with system Python + wheel_path = build_wheel(project_root) + + # Install using system Python's pip with --target + print(" Installing FastFlix wheel and dependencies...") + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + str(wheel_path), + "--target", + str(lib_dir), + "--no-warn-script-location", + "--no-cache-dir", + ], + check=True, + ) + + +def prepare_installer_resources(): + """Copy files needed by the Go installer for embedding (licenses, terms, etc.).""" + installer_dir = PROJECT_ROOT / "cmd" / "installer" + + # Copy licenses for go:embed + licenses_src = PROJECT_ROOT / "docs" / "build-licenses.txt" + licenses_dst = installer_dir / "licenses.txt" + if licenses_src.exists(): + shutil.copy2(licenses_src, licenses_dst) + print(f" Copied {licenses_src.name} -> {licenses_dst}") + + # Generate translated terms JSON for installer embedding + generate_terms_json(installer_dir) + + +def generate_terms_json(installer_dir): + """Generate translated terms text for all supported languages. + + Reads TERMS_SECTIONS from terms_agreement.py and translations from languages.yaml, + then writes a JSON file mapping language codes to rendered terms text. + """ + import json + + import yaml + + from fastflix.widgets.terms_agreement import TERMS_SECTIONS + + lang_file = PROJECT_ROOT / "fastflix" / "data" / "languages.yaml" + with open(lang_file, encoding="utf-8") as f: + lang_data = yaml.safe_load(f) + + languages = ["eng", "deu", "fra", "ita", "spa", "chs", "jpn", "rus", "por", "swe", "pol", "ukr", "kor", "ron"] + + result = {} + for lang in languages: + parts = [] + for header, body in TERMS_SECTIONS: + translated_header = lang_data.get(header, {}).get(lang, header) + if body: + translated_body = lang_data.get(body, {}).get(lang, body) + parts.append(f"{translated_header}\r\n{translated_body}") + else: + parts.append(translated_header) + result[lang] = "\r\n\r\n".join(parts) + + out = installer_dir / "terms_translations.json" + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(f" Generated terms_translations.json ({len(languages)} languages)") + + +def build_go_launcher(output_dir): + """Build the Go launcher binary and place it in the distribution.""" + from fastflix.version import __version__ + + launcher_exe = output_dir / "FastFlix.exe" + print(f" Building launcher -> {launcher_exe}") + subprocess.run( + [ + "go", + "build", + f"-ldflags=-s -w -X main.Version={__version__}", + "-o", + str(launcher_exe), + "./cmd/launcher", + ], + check=True, + cwd=PROJECT_ROOT, + ) + print(f" Launcher built ({launcher_exe.stat().st_size / 1024 / 1024:.1f} MB)") + + # Build standalone uninstaller + uninstaller_exe = output_dir / "uninstall.exe" + print(f" Building uninstaller -> {uninstaller_exe}") + subprocess.run( + [ + "go", + "build", + "-ldflags=-s -w", + "-o", + str(uninstaller_exe), + "./cmd/uninstaller", + ], + check=True, + cwd=PROJECT_ROOT, + ) + print(f" Uninstaller built ({uninstaller_exe.stat().st_size / 1024 / 1024:.1f} MB)") + + +def copy_data_files(output_dir, project_root): + """Copy additional data files that aren't part of the Python package.""" + print(" Copying additional data files...") + + # CHANGES file + changes = project_root / "CHANGES" + if changes.exists(): + shutil.copy2(changes, output_dir / "CHANGES") + + # Build licenses + licenses = project_root / "docs" / "build-licenses.txt" + if licenses.exists(): + shutil.copy2(licenses, output_dir / "build-licenses.txt") + + # Create build version file + version_file = output_dir / "build_version" + branch = "unknown" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + cwd=project_root, + ) + if result.returncode == 0: + branch = result.stdout.strip() + except FileNotFoundError: + pass + + from fastflix.version import __version__ + + timestamp = datetime.now().strftime("%Y.%m.%d-%H.%M") + version_file.write_text(f"{__version__}-{branch}-{timestamp}") + + +def create_dist_archive(output_dir, archive_path): + """Create a tar.zst archive of the distribution for the installer to embed. + + Uses Zstandard compression for better ratio than ZIP Deflate (~20-25% smaller) + while maintaining fast decompression during installation. + """ + import tarfile + + try: + import zstandard + except ImportError: + print(" ERROR: zstandard package not installed. Run: pip install zstandard") + sys.exit(1) + + print(f" Creating distribution archive: {archive_path}") + archive_path.parent.mkdir(parents=True, exist_ok=True) + + # Create tar in memory, then compress with zstd + tar_path = archive_path.with_suffix(".tar") + with tarfile.open(tar_path, "w") as tf: + for file in output_dir.rglob("*"): + if file.is_file(): + arcname = str(file.relative_to(output_dir)) + tf.add(file, arcname=arcname) + + # Compress with zstandard at level 22 (high compression, still fast decompress) + cctx = zstandard.ZstdCompressor(level=22, threads=-1) + with open(tar_path, "rb") as f_in, open(archive_path, "wb") as f_out: + cctx.copy_stream(f_in, f_out) + + tar_path.unlink() # Remove intermediate tar + + print(f" Archive size: {archive_path.stat().st_size / 1024 / 1024:.1f} MB") + + +def trim_pyside6(lib_dir): + """Remove unused PySide6 modules to dramatically reduce distribution size. + + FastFlix only uses QtCore, QtGui, QtWidgets (and QtSvg for icon rendering). + Uses a BLACKLIST approach: remove known-unnecessary large modules while + keeping all runtime DLL dependencies intact (pyside6.abi3.dll, VC runtime, etc.). + """ + print(" Trimming unused PySide6 modules...") + pyside6_dir = lib_dir / "PySide6" + if not pyside6_dir.exists(): + print(" WARNING: PySide6 directory not found") + return 0 + + removed_size = 0 + + # Large DLL modules to REMOVE (prefixes matched case-insensitively) + # These are Qt modules FastFlix does not use + remove_dll_prefixes = ( + "Qt6WebEngine", # Chromium browser engine (~193 MB) + "Qt6Quick", # QML/Quick UI framework (~35 MB) + "Qt6Qml", # QML engine (~12 MB) + "Qt6Designer", # Qt Designer (~7 MB) + "Qt63D", # 3D rendering (~8 MB) + "Qt6Pdf", # PDF rendering (~6 MB) + "Qt6Multimedia", # Multimedia playback (~2 MB) + "Qt6ShaderTools", # Shader compilation + "Qt6Bluetooth", + "Qt6Charts", + "Qt6DataVisualization", + "Qt6Graphs", + "Qt6HttpServer", + "Qt6Location", + "Qt6Nfc", + "Qt6Positioning", + "Qt6RemoteObjects", + "Qt6Scxml", + "Qt6Sensors", + "Qt6SerialBus", + "Qt6SerialPort", + "Qt6SpatialAudio", + "Qt6StateMachine", + "Qt6TextToSpeech", + "Qt6WebChannel", + "Qt6WebSockets", + "Qt6WebView", + ) + + # .pyd Python bindings to REMOVE + remove_pyd_prefixes = ( + "Qt3D", + "QtAxContainer", + "QtBluetooth", + "QtCharts", + "QtDataVisualization", + "QtDBus", + "QtDesigner", + "QtGraphs", + "QtHelp", + "QtHttpServer", + "QtLocation", + "QtMultimedia", + "QtNfc", + "QtOpenGL", + "QtPdf", + "QtPositioning", + "QtQml", + "QtQuick", + "QtRemoteObjects", + "QtScxml", + "QtSensors", + "QtSerialBus", + "QtSerialPort", + "QtSpatialAudio", + "QtSql", + "QtStateMachine", + "QtTest", + "QtTextToSpeech", + "QtUiTools", + "QtWebChannel", + "QtWebEngine", + "QtWebSockets", + "QtWebView", + "QtXml", + ) + + # Also remove the large software OpenGL renderer + remove_exact = {"opengl32sw.dll"} + + for f in pyside6_dir.iterdir(): + if not f.is_file(): + continue + name = f.name + should_remove = False + + if name in remove_exact: + should_remove = True + elif f.suffix.lower() == ".dll" and any(name.startswith(p) for p in remove_dll_prefixes): + should_remove = True + elif f.suffix.lower() == ".pyd" and any(name.startswith(p) for p in remove_pyd_prefixes): + should_remove = True + + if should_remove: + removed_size += f.stat().st_size + f.unlink() + + # Plugins: remove unused plugin directories + plugins_dir = pyside6_dir / "plugins" + if plugins_dir.exists(): + keep_plugin_dirs = {"iconengines", "imageformats", "platforms", "styles", "tls", "networkinformation"} + for plugin_dir in list(plugins_dir.iterdir()): + if plugin_dir.is_dir() and plugin_dir.name not in keep_plugin_dirs: + size = sum(f.stat().st_size for f in plugin_dir.rglob("*") if f.is_file()) + shutil.rmtree(plugin_dir, ignore_errors=True) + removed_size += size + + # Remove PDF plugin from imageformats (not needed) + pdf_plugin = plugins_dir / "imageformats" / "qpdf.dll" + if pdf_plugin.exists(): + removed_size += pdf_plugin.stat().st_size + pdf_plugin.unlink() + + # Remove subdirectories that are not needed at runtime + for dirname in ("qml", "resources", "translations", "typesystems"): + d = pyside6_dir / dirname + if d.exists(): + size = sum(f.stat().st_size for f in d.rglob("*") if f.is_file()) + shutil.rmtree(d, ignore_errors=True) + removed_size += size + + # Remove executables we don't need (designer, linguist, assistant, etc.) + for f in pyside6_dir.glob("*.exe"): + removed_size += f.stat().st_size + f.unlink() + + print(f" Removed {removed_size / 1024 / 1024:.1f} MB of unused PySide6 modules") + return removed_size + + +def cleanup_dist(lib_dir): + """Remove unnecessary files from the distribution to reduce size.""" + print(" Cleaning up distribution...") + removed_size = 0 + + patterns_to_remove = [ + # Python cache files + "**/__pycache__", + # Test directories + "**/tests", + "**/test", + # Documentation + "**/*.md", + "**/*.rst", + # Dist-info extras (keep METADATA and RECORD) + "**/*.dist-info/LICENSE*", + "**/*.dist-info/NOTICE*", + "**/*.dist-info/AUTHORS*", + # Type stubs (not needed at runtime) + "**/*.pyi", + # Pip itself (not needed at runtime) + "pip", + "pip-*", + ] + + for pattern in patterns_to_remove: + for path in lib_dir.glob(pattern): + if path.is_dir(): + size = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + shutil.rmtree(path, ignore_errors=True) + removed_size += size + elif path.is_file(): + removed_size += path.stat().st_size + path.unlink(missing_ok=True) + + # Trim PySide6 (biggest win) + removed_size += trim_pyside6(lib_dir) + + print(f" Total removed: {removed_size / 1024 / 1024:.1f} MB") + + +def main(): + parser = argparse.ArgumentParser(description="Build FastFlix distribution") + parser.add_argument("--python-version", default="3.13.1", help="Python version to embed") + parser.add_argument("--arch", default=None, help="Architecture: amd64 or arm64") + parser.add_argument("--output", default=None, help="Output directory") + parser.add_argument("--archive", action="store_true", help="Create tar.zst archive for installer embedding") + parser.add_argument("--skip-download", action="store_true", help="Use cached downloads only") + args = parser.parse_args() + + arch = args.arch or detect_arch() + output_dir = Path(args.output) if args.output else PROJECT_ROOT / "dist" / "FastFlix" + + print("Building FastFlix distribution") + print(f" Python: {args.python_version}") + print(f" Architecture: {arch}") + print(f" Output: {output_dir}") + print() + + # Clean output directory + if output_dir.exists(): + print("Cleaning previous build...") + shutil.rmtree(output_dir) + + python_dir = output_dir / "python" + lib_dir = output_dir / "lib" + + # Step 1: Download embeddable Python + print("[1/6] Downloading embeddable Python...") + python_zip = CACHE_DIR / f"python-{args.python_version}-embed-{arch}.zip" + if not python_zip.exists() and not args.skip_download: + download_file(get_python_url(args.python_version, arch), python_zip) + elif not python_zip.exists(): + print(f" ERROR: {python_zip} not found and --skip-download is set") + sys.exit(1) + else: + print(f" Using cached {python_zip.name}") + + # Step 2: Extract Python + print("[2/6] Extracting Python...") + extract_python(python_zip, python_dir) + + # Step 3: Configure ._pth file + print("[3/6] Configuring Python path...") + configure_pth_file(python_dir) + + # Step 4: Bootstrap pip + print("[4/6] Bootstrapping pip...") + bootstrap_pip(python_dir) + + # Step 5: Install dependencies + print("[5/6] Installing FastFlix and dependencies...") + install_dependencies(python_dir, lib_dir, PROJECT_ROOT) + + # Step 6: Build Go launcher + print("[6/7] Building Go launcher...") + build_go_launcher(output_dir) + + # Step 7: Finalize + print("[7/7] Finalizing distribution...") + copy_data_files(output_dir, PROJECT_ROOT) + cleanup_dist(lib_dir) + + # Calculate total size + total_size = sum(f.stat().st_size for f in output_dir.rglob("*") if f.is_file()) + print(f"\nDistribution complete: {total_size / 1024 / 1024:.1f} MB total") + print(f"Output: {output_dir}") + + # Optionally create archive for installer embedding + if args.archive: + print("Preparing installer resources...") + prepare_installer_resources() + archive_path = PROJECT_ROOT / "cmd" / "installer" / "fastflix_dist.tar.zst" + create_dist_archive(output_dir, archive_path) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..fa242d2c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,703 @@ +# -*- coding: utf-8 -*- +""" +Shared infrastructure for E2E encode tests. + +These tests build real FastFlix instances, run real FFmpeg/Rigaya encodes, +and verify output with ffprobe. They are local-only and skip on CI. + +Environment variables: + CI=true Skip all E2E tests + SKIP_NVIDIA=1 Skip NVEncC + FFmpeg NVENC encoders + SKIP_INTEL=1 Skip QSVEncC encoders + SKIP_AMD=1 Skip VCEEncC encoders +""" + +import json +import os +import shutil +import subprocess +from functools import lru_cache +from pathlib import Path +from typing import Optional + +import pytest +from box import Box +from platformdirs import user_data_dir + +from fastflix.flix import guess_bit_depth +from fastflix.models.config import Config +from fastflix.models.encode import ( + AOMAV1Settings, + AttachmentTrack, + AudioTrack, + CopySettings, + DataTrack, + FFmpegAV1NVENCSettings, + SubtitleTrack, + FFmpegNVENCSettings, + GIFSettings, + GifskiSettings, + NVEncCAV1Settings, + NVEncCAVCSettings, + NVEncCSettings, + QSVEncCAV1Settings, + QSVEncCH264Settings, + QSVEncCSettings, + SVTAV1Settings, + SVTAVIFSettings, + VCEEncCAV1Settings, + H264VideoToolboxSettings, + HEVCVideoToolboxSettings, + VCEEncCAVCSettings, + VCEEncCSettings, + VP9Settings, + VVCSettings, + WebPSettings, + rav1eSettings, + x264Settings, + x265Settings, +) +from fastflix.models.fastflix import FastFlix +from fastflix.models.video import Video, VideoSettings +from fastflix.widgets.panels.data_panel import NO_ATTACHMENT_EXTENSIONS, NO_DATA_EXTENSIONS + +# --------------------------------------------------------------------------- +# CI / hardware skip flags +# --------------------------------------------------------------------------- +ON_CI = os.environ.get("CI", "").lower() in ("true", "1", "yes") +SKIP_NVIDIA = os.environ.get("SKIP_NVIDIA", "").lower() in ("1", "true", "yes") +SKIP_INTEL = os.environ.get("SKIP_INTEL", "").lower() in ("1", "true", "yes") +SKIP_AMD = os.environ.get("SKIP_AMD", "").lower() in ("1", "true", "yes") +SKIP_MAC = os.environ.get("SKIP_MAC", "").lower() in ("1", "true", "yes") + +# --------------------------------------------------------------------------- +# Tool detection +# --------------------------------------------------------------------------- +FFMPEG = shutil.which("ffmpeg") +FFPROBE = shutil.which("ffprobe") + + +def _find_rigaya(app_name: str, binary_base: str) -> Optional[str]: + for name in (f"{binary_base}64", binary_base): + found = shutil.which(name) + if found: + return found + asset_folder = Path(user_data_dir(app_name, appauthor=False, roaming=True)) + if asset_folder.exists(): + for exe in asset_folder.glob(f"{binary_base}*64.exe"): + if exe.is_file(): + return str(exe) + for exe in asset_folder.glob(f"{binary_base}*.exe"): + if exe.is_file(): + return str(exe) + return None + + +NVENCC = _find_rigaya("NVEnc", "NVEncC") +QSVENCC = _find_rigaya("QSVEnc", "QSVEncC") +VCEENCC = _find_rigaya("VCEEnc", "VCEEncC") +GIFSKI = shutil.which("gifski") + + +def _get_ffmpeg_encoders() -> set[str]: + if not FFMPEG: + return set() + try: + result = subprocess.run( + [FFMPEG, "-encoders", "-hide_banner"], + capture_output=True, + text=True, + timeout=15, + ) + encoders = set() + for line in result.stdout.splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and len(parts[0]) == 6: + encoders.add(parts[1]) + return encoders + except Exception: + return set() + + +_ffmpeg_encoders = _get_ffmpeg_encoders() + + +def has_ffmpeg_encoder(name: str) -> bool: + return name in _ffmpeg_encoders + + +# --------------------------------------------------------------------------- +# Source files +# --------------------------------------------------------------------------- +MEDIA_DIR = Path(__file__).parent.parent / "media" + +SOURCES = { + "hdr10plus": MEDIA_DIR / "Beverly Hills Duck Pond - HDR10plus - Jessica Payne.mp4", + "attachments": MEDIA_DIR / "font_attachment_data.mkv", + "chapters": MEDIA_DIR / "chapters_timecode.mp4", +} + + +# --------------------------------------------------------------------------- +# Probe helper (cached) +# --------------------------------------------------------------------------- +@lru_cache(maxsize=8) +def probe_source(source_path: str) -> Optional[dict]: + if not FFPROBE or not Path(source_path).exists(): + return None + try: + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", source_path], + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(result.stdout) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# FastFlix builder +# --------------------------------------------------------------------------- +def create_fastflix( + source_path: Path, + encoder_settings, + output_path: Path, + work_path: Path, + is_rigaya: bool = False, + include_audio: bool = True, + include_subtitles: bool = False, + include_data: bool = True, + include_attachments: bool = True, +) -> FastFlix: + """Build a real FastFlix instance with tracks from the probed source.""" + probe = probe_source(str(source_path)) + assert probe is not None, f"Could not probe {source_path}" + + ext_with_dot = output_path.suffix.lower() + + # Parse streams by type + video_streams = [ + s + for s in probe["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0 + ] + audio_streams = [s for s in probe["streams"] if s["codec_type"] == "audio"] + subtitle_streams = [s for s in probe["streams"] if s["codec_type"] == "subtitle"] + data_streams = [s for s in probe["streams"] if s["codec_type"] == "data"] + attachment_streams = [s for s in probe["streams"] if s.get("codec_type") == "attachment"] + + # Cover images are video streams with attached_pic disposition + cover_streams = [ + s + for s in probe["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 1 + ] + + for stream in video_streams: + if "bits_per_raw_sample" in stream: + stream["bit_depth"] = int(stream["bits_per_raw_sample"]) + else: + stream["bit_depth"] = guess_bit_depth(stream.get("pix_fmt", ""), stream.get("color_primaries")) + + streams = Box( + { + "video": [Box(v) for v in video_streams], + "audio": [Box(a) for a in audio_streams], + "subtitle": [Box(s) for s in subtitle_streams], + "data": [Box(d) for d in data_streams], + "attachment": [Box(a) for a in attachment_streams], + } + ) + + video_settings = VideoSettings( + remove_hdr=False, + output_path=output_path, + end_time=2, + ) + video_settings.video_encoder_settings = encoder_settings + + video = Video( + source=source_path, + duration=float(probe.get("format", {}).get("duration", 10)), + streams=streams, + format=Box(probe.get("format", {})), + video_settings=video_settings, + work_path=work_path, + ) + + # --- Audio tracks --- + if include_audio: + outdex = 1 # after video + for stream in audio_streams: + video.audio_tracks.append( + AudioTrack( + index=stream["index"], + outdex=outdex, + codec=stream.get("codec_name", ""), + enabled=True, + raw_info=Box(stream), + ) + ) + outdex += 1 + + # --- Subtitle tracks --- + if include_subtitles: + sub_outdex = 1 + len(video.audio_tracks) + for stream in subtitle_streams: + dispositions = {k: bool(v) for k, v in stream.get("disposition", {}).items()} + video.subtitle_tracks.append( + SubtitleTrack( + index=stream["index"], + outdex=sub_outdex, + language=stream.get("tags", {}).get("language", ""), + title=stream.get("tags", {}).get("title", ""), + subtitle_type=stream.get("codec_name", "text"), + enabled=True, + dispositions=dispositions, + raw_info=Box(stream), + ) + ) + sub_outdex += 1 + + # --- Data / attachment tracks --- + data_outdex = 1 + len(video.audio_tracks) + len(video.subtitle_tracks) + + if include_data: + for stream in data_streams: + codec_name = stream.get("codec_name", "") + title = stream.get("tags", {}).get("title", "") + enabled = ext_with_dot not in NO_DATA_EXTENSIONS + video.data_tracks.append( + DataTrack( + index=stream["index"], + outdex=data_outdex if enabled else 0, + enabled=enabled, + codec_name=codec_name, + codec_type="data", + title=title, + ) + ) + if enabled: + data_outdex += 1 + + for stream in attachment_streams: + mimetype = stream.get("tags", {}).get("mimetype", "") + filename = stream.get("tags", {}).get("filename", "") + # Skip cover images (handled separately) + if mimetype.startswith("image"): + continue + enabled = ext_with_dot not in NO_ATTACHMENT_EXTENSIONS + video.data_tracks.append( + DataTrack( + index=stream["index"], + outdex=data_outdex if enabled else 0, + enabled=enabled, + codec_name=stream.get("codec_name", ""), + codec_type="attachment", + mimetype=mimetype, + filename=filename, + ) + ) + if enabled: + data_outdex += 1 + + # --- Cover attachment (extract from source if present) --- + if include_attachments and cover_streams: + cover_stream = cover_streams[0] + cover_ext = "png" if "png" in cover_stream.get("codec_name", "") else "jpg" + cover_path = work_path / f"cover.{cover_ext}" + subprocess.run( + [FFMPEG, "-y", "-i", str(source_path), "-map", f"0:{cover_stream['index']}", "-c", "copy", str(cover_path)], + capture_output=True, + timeout=30, + ) + if cover_path.exists(): + video.attachment_tracks.append( + AttachmentTrack( + index=cover_stream["index"], + outdex=data_outdex, + file_path=str(cover_path), + filename="cover", + attachment_type="cover", + ) + ) + + # --- Config --- + config = Config( + version="4.0.0", + ffmpeg=Path(FFMPEG), + ffprobe=Path(FFPROBE), + work_path=work_path, + ) + if is_rigaya: + if NVENCC: + config.nvencc = Path(NVENCC) + if QSVENCC: + config.qsvencc = Path(QSVENCC) + if VCEENCC: + config.vceencc = Path(VCEENCC) + if GIFSKI: + config.gifski = Path(GIFSKI) + + return FastFlix( + config=config, + encoders={}, + audio_encoders=[], + current_video=video, + ffmpeg_version="n5.0", + ) + + +# --------------------------------------------------------------------------- +# Command runner +# --------------------------------------------------------------------------- +def run_commands(commands, work_path: Path): + for cmd in commands: + cmd_to_run = cmd.to_string() if cmd.shell else cmd.to_list() + result = subprocess.run( + cmd_to_run, + capture_output=True, + text=True, + timeout=120, + shell=cmd.shell, + cwd=str(work_path), + ) + assert result.returncode == 0, ( + f"Command '{cmd.name}' failed (exit {result.returncode}):\n" + f"CMD: {cmd.to_string()}\n" + f"STDERR: {result.stderr[-2000:]}" + ) + + +# --------------------------------------------------------------------------- +# Output verifier +# --------------------------------------------------------------------------- +def verify_output(output_path: Path, expected_codec: str) -> dict: + assert output_path.exists(), f"Output file does not exist: {output_path}" + assert output_path.stat().st_size > 0, f"Output file is empty: {output_path}" + + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(output_path)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"ffprobe failed: {result.stderr}" + + data = json.loads(result.stdout) + video_streams = [ + s + for s in data["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0 + ] + assert len(video_streams) >= 1, "No video stream in output" + actual_codec = video_streams[0]["codec_name"] + assert actual_codec == expected_codec, f"Expected codec {expected_codec}, got {actual_codec}" + return data + + +def verify_lossless_quality(source_path: Path, output_path: Path, end_time: float = 2): + """Verify lossless encode by comparing per-frame MD5 checksums. + + Decodes both the source (trimmed to end_time) and the lossless output to raw + video frames and computes MD5 per frame. Every frame MD5 must match, proving + the encoder preserved pixels exactly with zero quality loss. + """ + source_result = subprocess.run( + [FFMPEG, "-to", str(end_time), "-i", str(source_path), "-map", "0:v", "-f", "framemd5", "-"], + capture_output=True, + text=True, + timeout=120, + ) + assert source_result.returncode == 0, f"framemd5 on source failed: {source_result.stderr[-1000:]}" + + output_result = subprocess.run( + [FFMPEG, "-i", str(output_path), "-map", "0:v", "-f", "framemd5", "-"], + capture_output=True, + text=True, + timeout=120, + ) + assert output_result.returncode == 0, f"framemd5 on output failed: {output_result.stderr[-1000:]}" + + # Parse frame lines (skip comment lines starting with #) + source_frames = [line for line in source_result.stdout.splitlines() if line and not line.startswith("#")] + output_frames = [line for line in output_result.stdout.splitlines() if line and not line.startswith("#")] + + assert len(source_frames) > 0, "No frames decoded from source" + assert len(output_frames) > 0, "No frames decoded from output" + assert len(source_frames) == len(output_frames), ( + f"Frame count mismatch: source={len(source_frames)}, output={len(output_frames)}" + ) + + # Extract MD5 hashes (last field in each framemd5 line) + mismatched = [] + for i, (src_line, out_line) in enumerate(zip(source_frames, output_frames)): + src_md5 = src_line.split(",")[-1].strip() + out_md5 = out_line.split(",")[-1].strip() + if src_md5 != out_md5: + mismatched.append(f"Frame {i}: source={src_md5} output={out_md5}") + + assert not mismatched, ( + f"Lossless verification failed — {len(mismatched)}/{len(source_frames)} frames differ:\n" + + "\n".join(mismatched[:10]) + ) + + +# --------------------------------------------------------------------------- +# Encoder definitions +# --------------------------------------------------------------------------- +def _skip_if(*conditions): + """Return True if ANY condition is True (meaning test should skip).""" + return lambda: any(c() if callable(c) else c for c in conditions) + + +# (encoder_id, settings, output_ext, expected_codec, skip_condition, is_rigaya) +FFMPEG_ENCODERS = [ + ( + "hevc_x265", + x265Settings(preset="ultrafast", crf=51), + ".mkv", + "hevc", + lambda: not has_ffmpeg_encoder("libx265"), + False, + ), + ( + "avc_x264", + x264Settings(preset="ultrafast", crf=51, pix_fmt="yuv420p"), + ".mkv", + "h264", + lambda: not has_ffmpeg_encoder("libx264"), + False, + ), + ("svt_av1", SVTAV1Settings(speed="13", qp=63), ".mkv", "av1", lambda: not has_ffmpeg_encoder("libsvtav1"), False), + ( + "av1_aom", + AOMAV1Settings(cpu_used="8", usage="realtime", crf=63), + ".mkv", + "av1", + lambda: not has_ffmpeg_encoder("libaom-av1"), + False, + ), + ("rav1e", rav1eSettings(speed="10", qp=255), ".mkv", "av1", lambda: not has_ffmpeg_encoder("librav1e"), False), + ( + "vp9", + VP9Settings(speed="5", quality="realtime", crf=63, single_pass=True), + ".mkv", + "vp9", + lambda: not has_ffmpeg_encoder("libvpx-vp9"), + False, + ), + ("vvc", VVCSettings(preset="faster", qp=51), ".mkv", "vvc", lambda: not has_ffmpeg_encoder("libvvenc"), False), + ("copy", CopySettings(), ".mkv", "h264", lambda: False, False), +] + +NVIDIA_ENCODERS = [ + ( + "ffmpeg_hevc_nvenc", + FFmpegNVENCSettings(preset="p1", bitrate="1000k"), + ".mkv", + "hevc", + _skip_if(lambda: not has_ffmpeg_encoder("hevc_nvenc"), lambda: SKIP_NVIDIA), + False, + ), + ( + "ffmpeg_av1_nvenc", + FFmpegAV1NVENCSettings(preset="p1", bitrate="1000k"), + ".mkv", + "av1", + _skip_if(lambda: not has_ffmpeg_encoder("av1_nvenc"), lambda: SKIP_NVIDIA), + False, + ), + ( + "nvencc_hevc", + NVEncCSettings(preset="performance", bitrate="1000k"), + ".mkv", + "hevc", + _skip_if(lambda: not NVENCC, lambda: SKIP_NVIDIA), + True, + ), + ( + "nvencc_avc", + NVEncCAVCSettings(preset="performance", bitrate="1000k"), + ".mkv", + "h264", + _skip_if(lambda: not NVENCC, lambda: SKIP_NVIDIA), + True, + ), + ( + "nvencc_av1", + NVEncCAV1Settings(preset="performance", bitrate="1000k"), + ".mkv", + "av1", + _skip_if(lambda: not NVENCC, lambda: SKIP_NVIDIA), + True, + ), +] + +INTEL_ENCODERS = [ + ( + "qsvencc_hevc", + QSVEncCSettings(preset="fastest", bitrate="1000k"), + ".mkv", + "hevc", + _skip_if(lambda: not QSVENCC, lambda: SKIP_INTEL), + True, + ), + ( + "qsvencc_avc", + QSVEncCH264Settings(preset="fastest", bitrate="1000k"), + ".mkv", + "h264", + _skip_if(lambda: not QSVENCC, lambda: SKIP_INTEL), + True, + ), + ( + "qsvencc_av1", + QSVEncCAV1Settings(preset="fastest", bitrate="1000k"), + ".mkv", + "av1", + _skip_if(lambda: not QSVENCC, lambda: SKIP_INTEL), + True, + ), +] + +AMD_ENCODERS = [ + ( + "vceencc_hevc", + VCEEncCSettings(preset="fast", bitrate="1000k"), + ".mkv", + "hevc", + _skip_if(lambda: not VCEENCC, lambda: SKIP_AMD), + True, + ), + ( + "vceencc_avc", + VCEEncCAVCSettings(preset="fast", bitrate="1000k"), + ".mkv", + "h264", + _skip_if(lambda: not VCEENCC, lambda: SKIP_AMD), + True, + ), + ( + "vceencc_av1", + VCEEncCAV1Settings(preset="fast", bitrate="1000k"), + ".mkv", + "av1", + _skip_if(lambda: not VCEENCC, lambda: SKIP_AMD), + True, + ), +] + +GIF_ENCODERS = [ + ("gif", GIFSettings(fps="5"), ".gif", "gif", lambda: False, False), + ("gifski", GifskiSettings(fps="5", fast=True, quality="1"), ".gif", "gif", lambda: not GIFSKI, False), + ( + "webp", + WebPSettings(compression="0", qscale=1), + ".webp", + "webp", + lambda: not has_ffmpeg_encoder("libwebp"), + False, + ), + ( + "svt_av1_avif", + SVTAVIFSettings(speed="13", qp=63), + ".avif", + "av1", + lambda: not has_ffmpeg_encoder("libsvtav1"), + False, + ), +] + +LOSSLESS_ENCODERS = [ + ( + "hevc_x265", + x265Settings(preset="ultrafast", lossless=True), + ".mkv", + "hevc", + lambda: not has_ffmpeg_encoder("libx265"), + False, + ), + ( + "avc_x264", + x264Settings(preset="ultrafast", lossless=True, pix_fmt="yuv420p10le"), + ".mkv", + "h264", + lambda: not has_ffmpeg_encoder("libx264"), + False, + ), + ( + "av1_aom", + AOMAV1Settings(cpu_used="8", usage="realtime", lossless=True), + ".mkv", + "av1", + lambda: not has_ffmpeg_encoder("libaom-av1"), + False, + ), + ( + "vp9", + VP9Settings(speed="5", quality="realtime", lossless=True, single_pass=True), + ".mkv", + "vp9", + lambda: not has_ffmpeg_encoder("libvpx-vp9"), + False, + ), + ( + "svt_av1", + SVTAV1Settings(speed="13", lossless=True), + ".mkv", + "av1", + lambda: not has_ffmpeg_encoder("libsvtav1"), + False, + ), + ( + "webp", + WebPSettings(lossless="yes", compression="0"), + ".webp", + "webp", + lambda: not has_ffmpeg_encoder("libwebp"), + False, + ), +] + +MAC_ENCODERS = [ + ( + "hevc_videotoolbox", + HEVCVideoToolboxSettings(q=50), + ".mp4", + "hevc", + _skip_if(lambda: not has_ffmpeg_encoder("hevc_videotoolbox"), lambda: SKIP_MAC), + False, + ), + ( + "h264_videotoolbox", + H264VideoToolboxSettings(q=50), + ".mp4", + "h264", + _skip_if(lambda: not has_ffmpeg_encoder("h264_videotoolbox"), lambda: SKIP_MAC), + False, + ), +] + +ALL_ENCODERS = FFMPEG_ENCODERS + NVIDIA_ENCODERS + INTEL_ENCODERS + AMD_ENCODERS + MAC_ENCODERS + GIF_ENCODERS + +# Encoders that support audio/subtitles/data/attachments (not GIF/image formats) +FULL_FEATURE_ENCODERS = FFMPEG_ENCODERS + NVIDIA_ENCODERS + INTEL_ENCODERS + AMD_ENCODERS + MAC_ENCODERS + + +def make_params(encoder_list): + """Convert encoder tuples into pytest.param entries with proper skip marks.""" + return [ + pytest.param( + enc_id, + settings, + ext, + codec, + is_rigaya, + id=enc_id, + marks=pytest.mark.skipif(skip_fn(), reason=f"{enc_id}: required tool not available"), + ) + for enc_id, settings, ext, codec, skip_fn, is_rigaya in encoder_list + ] diff --git a/tests/e2e/lossless_investigation.py b/tests/e2e/lossless_investigation.py new file mode 100644 index 00000000..473e8c0c --- /dev/null +++ b/tests/e2e/lossless_investigation.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python +""" +Investigation script: verify which encoders produce truly bit-exact lossless output. + +Run: uv run python tests/e2e/test_lossless_investigation.py +""" + +import shutil +import subprocess +import tempfile +from pathlib import Path + +FFMPEG = shutil.which("ffmpeg") +FFPROBE = shutil.which("ffprobe") +MEDIA_DIR = Path(__file__).parent.parent / "media" +HDR_SOURCE = MEDIA_DIR / "Beverly Hills Duck Pond - HDR10plus - Jessica Payne.mp4" + + +def run(cmd, timeout=120): + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +def get_pix_fmt(path): + r = run([FFPROBE, "-v", "quiet", "-show_entries", "stream=pix_fmt", "-of", "csv=p=0", str(path)]) + return r.stdout.strip().split("\n")[0].strip().rstrip(",") + + +def get_framemd5(path, pix_fmt=None, to=None): + cmd = [FFMPEG] + if to: + cmd += ["-to", str(to)] + cmd += ["-i", str(path), "-map", "0:v"] + if pix_fmt: + cmd += ["-pix_fmt", pix_fmt] + cmd += ["-f", "framemd5", "-"] + r = run(cmd) + return [line for line in r.stdout.splitlines() if line and not line.startswith("#")] + + +def get_psnr(path1, path2, to1=None, to2=None): + cmd = [FFMPEG] + if to1: + cmd += ["-to", str(to1)] + cmd += ["-i", str(path1)] + if to2: + cmd += ["-to", str(to2)] + cmd += ["-i", str(path2), "-lavfi", "[0:v][1:v]psnr", "-f", "null", "-"] + r = run(cmd) + for line in r.stderr.splitlines(): + if "average:" in line and "PSNR" in line: + return line + return f"PSNR not found in: {r.stderr[-500:]}" + + +def compare_framemd5(frames_a, frames_b): + if len(frames_a) != len(frames_b): + return f"Frame count mismatch: {len(frames_a)} vs {len(frames_b)}" + mismatched = 0 + for a, b in zip(frames_a, frames_b): + md5_a = a.split(",")[-1].strip() + md5_b = b.split(",")[-1].strip() + if md5_a != md5_b: + mismatched += 1 + if mismatched == 0: + return "EXACT MATCH (all frames identical)" + return f"MISMATCH: {mismatched}/{len(frames_a)} frames differ" + + +def test_encoder(name, encode_cmd, source, tmp_dir, end_time=2): + """Test a single encoder's lossless mode.""" + output = tmp_dir / f"lossless_{name}.mkv" + cmd = [FFMPEG, "-y", "-to", str(end_time), "-i", str(source)] + encode_cmd + [str(output)] + r = run(cmd) + if r.returncode != 0: + return {"status": "ENCODE FAILED", "error": r.stderr[-500:]} + + src_fmt = get_pix_fmt(source) + out_fmt = get_pix_fmt(output) + + results = { + "status": "OK", + "source_pix_fmt": src_fmt, + "output_pix_fmt": out_fmt, + } + + # Test 1: Direct framemd5 comparison (native pixel formats) + src_frames = get_framemd5(source, to=end_time) + out_frames = get_framemd5(output) + results["native_comparison"] = compare_framemd5(src_frames, out_frames) + + # Test 2: Force both to same pix_fmt (source's format) + src_frames_forced = get_framemd5(source, pix_fmt=src_fmt, to=end_time) + out_frames_forced = get_framemd5(output, pix_fmt=src_fmt) + results["forced_src_fmt"] = compare_framemd5(src_frames_forced, out_frames_forced) + + # Test 3: Force both to output's pix_fmt + if src_fmt != out_fmt: + src_frames_out_fmt = get_framemd5(source, pix_fmt=out_fmt, to=end_time) + out_frames_out_fmt = get_framemd5(output, pix_fmt=out_fmt) + results["forced_out_fmt"] = compare_framemd5(src_frames_out_fmt, out_frames_out_fmt) + + # Test 4: Round-trip (re-encode output losslessly, compare decoded frames) + reencoded = tmp_dir / f"reencoded_{name}.mkv" + re_cmd = [FFMPEG, "-y", "-i", str(output)] + encode_cmd + [str(reencoded)] + r2 = run(re_cmd) + if r2.returncode == 0: + out1_frames = get_framemd5(output) + out2_frames = get_framemd5(reencoded) + results["round_trip"] = compare_framemd5(out1_frames, out2_frames) + else: + results["round_trip"] = f"RE-ENCODE FAILED: {r2.stderr[-200:]}" + + # Test 5: PSNR (for reference) + psnr_line = get_psnr(source, output, to1=end_time) + results["psnr"] = psnr_line.strip() if psnr_line else "N/A" + + return results + + +def main(): + tmp_dir = Path(tempfile.mkdtemp()) + print(f"Working in: {tmp_dir}\n") + + # Create a simple synthetic 8-bit test source (avoids HDR/color issues) + synth_source = tmp_dir / "synth.mkv" + run( + [ + FFMPEG, + "-y", + "-f", + "lavfi", + "-i", + "testsrc=duration=1:size=320x240:rate=10", + "-pix_fmt", + "yuv420p", + str(synth_source), + ] + ) + synth_fmt = get_pix_fmt(synth_source) + + # Define all lossless encoder configurations to test + encoders = { + # (name, encode_args, source, end_time) + "x265_synth": ( + ["-map", "0:v", "-c:v", "libx265", "-x265-params", "lossless=1", "-preset", "ultrafast", "-an"], + synth_source, + 1, + ), + "x264_synth": ( + ["-map", "0:v", "-c:v", "libx264", "-x264-params", "lossless=1", "-preset", "ultrafast", "-an"], + synth_source, + 1, + ), + "vp9_synth": ( + ["-map", "0:v", "-c:v", "libvpx-vp9", "-lossless", "1", "-an"], + synth_source, + 1, + ), + "aom_synth": ( + ["-map", "0:v", "-c:v", "libaom-av1", "-lossless", "1", "-cpu-used", "8", "-an"], + synth_source, + 1, + ), + "aom_synth_usage_good": ( + ["-map", "0:v", "-c:v", "libaom-av1", "-lossless", "1", "-cpu-used", "8", "-usage", "good", "-an"], + synth_source, + 1, + ), + "aom_synth_via_params": ( + ["-map", "0:v", "-c:v", "libaom-av1", "-aom-params", "lossless=1", "-cpu-used", "8", "-an"], + synth_source, + 1, + ), + "svt_synth": ( + ["-map", "0:v", "-c:v", "libsvtav1", "-svtav1-params", "lossless=1", "-preset", "13", "-an"], + synth_source, + 1, + ), + } + + # Also test with the real HDR10+ source if available + if HDR_SOURCE.exists(): + hdr_fmt = get_pix_fmt(HDR_SOURCE) + print(f"HDR source pixel format: {hdr_fmt}\n") + encoders.update( + { + "x265_hdr": ( + [ + "-map", + "0:v", + "-c:v", + "libx265", + "-pix_fmt", + "yuv420p10le", + "-x265-params", + "lossless=1", + "-preset", + "ultrafast", + "-an", + ], + HDR_SOURCE, + 2, + ), + "x264_hdr_10bit": ( + [ + "-map", + "0:v", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p10le", + "-x264-params", + "lossless=1", + "-preset", + "ultrafast", + "-an", + ], + HDR_SOURCE, + 2, + ), + "x264_hdr_8bit": ( + [ + "-map", + "0:v", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-x264-params", + "lossless=1", + "-preset", + "ultrafast", + "-an", + ], + HDR_SOURCE, + 2, + ), + "vp9_hdr": ( + ["-map", "0:v", "-c:v", "libvpx-vp9", "-pix_fmt", "yuv420p10le", "-lossless", "1", "-an"], + HDR_SOURCE, + 2, + ), + "aom_hdr": ( + [ + "-map", + "0:v", + "-c:v", + "libaom-av1", + "-pix_fmt", + "yuv420p10le", + "-lossless", + "1", + "-cpu-used", + "8", + "-an", + ], + HDR_SOURCE, + 2, + ), + "aom_hdr_no_crf": ( + [ + "-map", + "0:v", + "-c:v", + "libaom-av1", + "-pix_fmt", + "yuv420p10le", + "-lossless", + "1", + "-cpu-used", + "8", + "-usage", + "good", + "-an", + ], + HDR_SOURCE, + 2, + ), + "aom_hdr_with_crf": ( + [ + "-map", + "0:v", + "-c:v", + "libaom-av1", + "-pix_fmt", + "yuv420p10le", + "-lossless", + "1", + "-cpu-used", + "8", + "-crf", + "26", + "-an", + ], + HDR_SOURCE, + 2, + ), + "svt_hdr": ( + [ + "-map", + "0:v", + "-c:v", + "libsvtav1", + "-pix_fmt", + "yuv420p10le", + "-svtav1-params", + "lossless=1", + "-preset", + "13", + "-an", + ], + HDR_SOURCE, + 2, + ), + } + ) + + print(f"Synthetic source pixel format: {synth_fmt}") + print(f"Testing {len(encoders)} encoder configurations...\n") + print("=" * 80) + + for name, (encode_cmd, source, end_time) in sorted(encoders.items()): + print(f"\n### {name}") + results = test_encoder(name, encode_cmd, source, tmp_dir, end_time) + + if results["status"] != "OK": + print(f" STATUS: {results['status']}") + print(f" ERROR: {results.get('error', 'unknown')[:300]}") + continue + + print(f" Pixel format: {results['source_pix_fmt']} -> {results['output_pix_fmt']}") + print(f" Native framemd5: {results['native_comparison']}") + print(f" Forced source fmt: {results['forced_src_fmt']}") + if "forced_out_fmt" in results: + print(f" Forced output fmt: {results['forced_out_fmt']}") + print(f" Round-trip: {results['round_trip']}") + psnr_short = results["psnr"] + if "average:" in psnr_short: + # Extract just the key PSNR values + import re + + m = re.search(r"PSNR y:([\d.]+|inf).*average:([\d.]+|inf)", psnr_short) + if m: + psnr_short = f"Y={m.group(1)} Average={m.group(2)}" + print(f" PSNR: {psnr_short}") + + print("\n" + "=" * 80) + print("DONE") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/test_e2e_encode.py b/tests/e2e/test_e2e_encode.py new file mode 100644 index 00000000..d910fb18 --- /dev/null +++ b/tests/e2e/test_e2e_encode.py @@ -0,0 +1,923 @@ +# -*- coding: utf-8 -*- +""" +E2E encode tests — build real commands, run real FFmpeg/Rigaya encodes, verify output. + +Run locally: + uv run pytest tests/e2e -v + uv run pytest tests/e2e -v -k "x265" + SKIP_NVIDIA=1 uv run pytest tests/e2e -v + +Skipped on CI (GitHub Actions sets CI=true). +""" + +import json +import subprocess + +import pytest + +from tests.e2e.conftest import ( + ALL_ENCODERS, + FFMPEG, + FFMPEG_ENCODERS, + FFPROBE, + LOSSLESS_ENCODERS, + MEDIA_DIR, + NVENCC, + ON_CI, + SKIP_NVIDIA, + SOURCES, + create_fastflix, + has_ffmpeg_encoder, + make_params, + run_commands, + verify_lossless_quality, + verify_output, +) +from fastflix.models.encode import NVEncCSettings, x264Settings + +pytestmark = [pytest.mark.e2e] + +if ON_CI: + pytestmark.append(pytest.mark.skip(reason="E2E tests skipped on CI")) +if not FFMPEG or not FFPROBE: + pytestmark.append(pytest.mark.skip(reason="ffmpeg/ffprobe not found")) + + +# =========================================================================== +# Test 1: Basic encode — every encoder x HDR10+ source +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(ALL_ENCODERS)) +def test_encode_basic(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """Every encoder can encode the HDR10+ test source.""" + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"basic_{encoder_id}{output_ext}" + work_path = tmp_path / "work" + work_path.mkdir() + + # copy encoder uses source codec — HDR10+ source is HEVC + if encoder_id == "copy": + expected_codec = "hevc" + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands, f"Encoder {encoder_id} returned no commands" + + run_commands(commands, work_path) + verify_output(output_path, expected_codec) + + +# =========================================================================== +# Test 2: Attachments — FFmpeg encoders x MKV source with fonts/cover +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(FFMPEG_ENCODERS)) +def test_encode_with_attachments(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """FFmpeg encoders preserve font attachments and cover when encoding MKV.""" + source = SOURCES["attachments"] + if not source.exists(): + pytest.skip("Attachment test source not found") + + # copy encoder: source is h264 + if encoder_id == "copy": + expected_codec = "h264" + + output_path = tmp_path / f"attach_{encoder_id}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + run_commands(commands, work_path) + data = verify_output(output_path, expected_codec) + + # Check font attachment preserved with mimetype + attachments = [s for s in data["streams"] if s["codec_type"] == "attachment"] + font_attachments = [s for s in attachments if s.get("tags", {}).get("mimetype") == "application/x-truetype-font"] + assert len(font_attachments) >= 1, f"Font attachment missing. Found: {attachments}" + + # Check cover image preserved + covers = [ + s + for s in data["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 1 + ] + assert len(covers) >= 1, f"Cover image missing. Streams: {[s['codec_type'] for s in data['streams']]}" + + +# =========================================================================== +# Test 3: Data streams excluded for MKV output +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(FFMPEG_ENCODERS)) +def test_encode_data_excluded_for_mkv(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """Data streams from MP4 source are excluded when encoding to MKV.""" + source = SOURCES["chapters"] + if not source.exists(): + pytest.skip("Chapters test source not found") + + if encoder_id == "copy": + expected_codec = "h264" + + output_path = tmp_path / f"nodata_{encoder_id}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + # Verify -dn is in the final command (multi-pass encoders have it in the last pass) + final_cmd_str = commands[-1].to_string() + assert "-dn" in final_cmd_str, f"Expected -dn to exclude data streams for MKV: {final_cmd_str}" + + run_commands(commands, work_path) + data = verify_output(output_path, expected_codec) + + # No data streams in output + out_data = [s for s in data["streams"] if s["codec_type"] == "data"] + assert len(out_data) == 0, f"Data streams should be excluded from MKV. Found: {out_data}" + + +# =========================================================================== +# Test 4: Data streams mapped for MP4 output +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(FFMPEG_ENCODERS)) +def test_encode_data_mapped_for_mp4(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """Data streams from MP4 source are mapped (not excluded) when encoding to MP4.""" + source = SOURCES["chapters"] + if not source.exists(): + pytest.skip("Chapters test source not found") + + # Skip encoders that can't output MP4 + if encoder_id in ("vp9", "vvc"): + pytest.skip(f"{encoder_id} doesn't typically output MP4") + if encoder_id == "copy": + expected_codec = "h264" + + output_path = tmp_path / f"data_{encoder_id}.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + # Verify the final command maps the data stream and sets title/handler metadata + final_cmd_str = commands[-1].to_string() + assert "-map 0:2" in final_cmd_str, f"Data stream should be mapped for MP4: {final_cmd_str}" + assert "-c:d copy" in final_cmd_str, f"Data stream should use copy codec: {final_cmd_str}" + assert "title=" in final_cmd_str, f"Data track should have title metadata: {final_cmd_str}" + assert "handler=" in final_cmd_str, f"Data track should have handler metadata: {final_cmd_str}" + assert "-dn" not in final_cmd_str, f"Should NOT have -dn when data is mapped: {final_cmd_str}" + + # Encode must succeed (even if FFmpeg silently drops the data stream) + run_commands(commands, work_path) + verify_output(output_path, expected_codec) + + +# =========================================================================== +# Test 5: "Same as Source" extension — MKV source → .mkv output +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(FFMPEG_ENCODERS)) +def test_encode_same_as_source_mkv(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """'Same as Source' with MKV source resolves to .mkv output.""" + source = SOURCES["attachments"] # MKV source + if not source.exists(): + pytest.skip("Attachment MKV test source not found") + + if encoder_id == "copy": + expected_codec = "h264" + + # Use .mkv extension (simulating "Same as Source" resolution for MKV input) + output_path = tmp_path / f"same_mkv_{encoder_id}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + ) + + # Verify the resolved extension matches source + assert source.suffix.lower() == ".mkv" + assert output_path.suffix.lower() == ".mkv" + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + run_commands(commands, work_path) + verify_output(output_path, expected_codec) + + +# =========================================================================== +# Test 6: "Same as Source" extension — MP4 source → .mp4 output +# =========================================================================== +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(FFMPEG_ENCODERS)) +def test_encode_same_as_source_mp4(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """'Same as Source' with MP4 source resolves to .mp4 output.""" + source = SOURCES["chapters"] # MP4 source + if not source.exists(): + pytest.skip("Chapters MP4 test source not found") + + # Skip encoders that can't output MP4 + if encoder_id in ("vp9", "vvc"): + pytest.skip(f"{encoder_id} doesn't typically output MP4") + if encoder_id == "copy": + expected_codec = "h264" + + # Use .mp4 extension (simulating "Same as Source" resolution for MP4 input) + output_path = tmp_path / f"same_mp4_{encoder_id}.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + ) + + # Verify the resolved extension matches source + assert source.suffix.lower() == ".mp4" + assert output_path.suffix.lower() == ".mp4" + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + run_commands(commands, work_path) + verify_output(output_path, expected_codec) + + +# =========================================================================== +# Test 7: Subtitle disposition flags in encode output +# =========================================================================== +def test_encode_subtitle_default_disposition(tmp_path): + """Encode MKV with subtitles, verify default disposition flag appears in command.""" + source = SOURCES["attachments"] # MKV with ASS subtitle + if not source.exists(): + pytest.skip("Attachment MKV test source not found") + + output_path = tmp_path / "sub_disp.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + from fastflix.models.encode import x265Settings + + fastflix = create_fastflix( + source_path=source, + encoder_settings=x265Settings(preset="ultrafast", crf=51), + output_path=output_path, + work_path=work_path, + include_subtitles=True, + ) + + # Set default disposition on first subtitle (simulating "first" profile mode) + assert len(fastflix.current_video.subtitle_tracks) >= 1, "Source should have subtitles" + fastflix.current_video.subtitle_tracks[0].dispositions["default"] = True + + from fastflix.encoders.hevc_x265 import command_builder + + commands = command_builder.build(fastflix) + assert commands + + # Final command should contain disposition with "default" + final_cmd = commands[-1].to_string() + assert "-disposition:" in final_cmd, f"Expected disposition flag in command: {final_cmd}" + assert "default" in final_cmd, f"Expected 'default' disposition: {final_cmd}" + + run_commands(commands, work_path) + data = verify_output(output_path, "hevc") + + # Verify subtitle stream exists in output + out_subs = [s for s in data["streams"] if s["codec_type"] == "subtitle"] + assert len(out_subs) >= 1, f"Subtitle should be in output. Streams: {[s['codec_type'] for s in data['streams']]}" + + +# =========================================================================== +# Test 8: AOM AV1 2-pass CRF encode +# =========================================================================== +def test_encode_aom_av1_two_pass_crf(tmp_path): + """AOM AV1 2-pass CRF produces two commands and encodes successfully.""" + if not has_ffmpeg_encoder("libaom-av1"): + pytest.skip("libaom-av1 not available") + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "aom_2pass_crf.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + from fastflix.models.encode import AOMAV1Settings + + fastflix = create_fastflix( + source_path=source, + encoder_settings=AOMAV1Settings(cpu_used="8", usage="realtime", crf=63, single_pass=False), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.av1_aom import command_builder + + commands = command_builder.build(fastflix) + assert len(commands) == 2, f"Expected 2-pass (2 commands), got {len(commands)}" + assert "First Pass CRF" in commands[0].name + assert "Second Pass CRF" in commands[1].name + + # Verify pass flags in commands + cmd1_str = commands[0].to_string() + cmd2_str = commands[1].to_string() + assert "-pass 1" in cmd1_str + assert "-pass 2" in cmd2_str + assert "-crf 63" in cmd1_str + assert "-crf 63" in cmd2_str + + run_commands(commands, work_path) + verify_output(output_path, "av1") + + +# =========================================================================== +# Test 9: Lossless command flags — verify lossless flag in generated commands +# =========================================================================== +# Rate control flags that must NOT appear in lossless commands (per encoder) +_LOSSLESS_FORBIDDEN_FLAGS = { + "hevc_x265": ["-crf:v", "-b:v"], + "avc_x264": ["-crf:v", "-b:v"], + "av1_aom": ["-crf", "-b:v"], + "vp9": ["-crf:v", "-b:v"], + "svt_av1": ["-crf", "-qp", "-b:v"], + "webp": [], # WebP has its own quality model, no CRF/bitrate flags to check +} + + +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(LOSSLESS_ENCODERS)) +def test_lossless_command_flags(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """Lossless encoders include the lossless flag and exclude rate control flags.""" + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"lossless_{encoder_id}{output_ext}" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands, f"Encoder {encoder_id} returned no commands" + + # Verify lossless flag is present in at least one command + all_cmd_str = " ".join(cmd.to_string() for cmd in commands) + assert "lossless" in all_cmd_str.lower(), f"Expected 'lossless' in command for {encoder_id}: {all_cmd_str}" + + # Verify rate control flags are NOT present — lossless must skip CRF/bitrate/QP + for flag in _LOSSLESS_FORBIDDEN_FLAGS.get(encoder_id, []): + for cmd in commands: + cmd_list = cmd.to_list() + assert flag not in cmd_list, ( + f"Lossless {encoder_id} must not include '{flag}' in command: {cmd.to_string()}" + ) + + +# =========================================================================== +# Test 10: Lossless encode — full encode with framemd5 quality verification +# =========================================================================== +# Encoders confirmed to produce bit-exact lossless output with 10-bit HDR content +BIT_EXACT_LOSSLESS = {"hevc_x265", "avc_x264", "vp9"} + + +@pytest.mark.parametrize("encoder_id,settings,output_ext,expected_codec,is_rigaya", make_params(LOSSLESS_ENCODERS)) +def test_lossless_encode(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp_path): + """Lossless encoders produce valid output with zero quality loss.""" + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"lossless_{encoder_id}{output_ext}" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output_path, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands, f"Encoder {encoder_id} returned no commands" + + run_commands(commands, work_path) + verify_output(output_path, expected_codec) + + # Verify true lossless via per-frame MD5 comparison for bit-exact encoders + if encoder_id in BIT_EXACT_LOSSLESS: + verify_lossless_quality(source, output_path) + + +# =========================================================================== +# Filter E2E test helpers +# =========================================================================== + + +def sample_positions_5(h, w): + return [ + (h // 4, w // 4), + (h // 4, 3 * w // 4), + (h // 2, w // 2), + (3 * h // 4, w // 4), + (3 * h // 4, 3 * w // 4), + ] + + +def _get_frame_rgb(video_path): + """Decode first frame to raw RGB24 bytes, returning (pixels, width, height).""" + probe = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", str(video_path)], + capture_output=True, + text=True, + timeout=30, + ) + data = json.loads(probe.stdout) + vs = [s for s in data["streams"] if s["codec_type"] == "video"][0] + w, h = int(vs["width"]), int(vs["height"]) + + result = subprocess.run( + [FFMPEG, "-i", str(video_path), "-frames:v", "1", "-pix_fmt", "rgb24", "-f", "rawvideo", "-"], + capture_output=True, + timeout=30, + ) + assert result.returncode == 0, f"Failed to decode {video_path}" + assert len(result.stdout) >= w * h * 3 + return result.stdout, w, h + + +def _avg_rgb(pixels, w, h): + """Compute average R, G, B across the entire frame.""" + r_sum = g_sum = b_sum = 0 + count = w * h + for i in range(0, count * 3, 3): + r_sum += pixels[i] + g_sum += pixels[i + 1] + b_sum += pixels[i + 2] + return r_sum / count, g_sum / count, b_sum / count + + +def _avg_luma(pixels, w, h): + """Approximate average luma (BT.601) across the frame.""" + total = 0.0 + count = w * h + for i in range(0, count * 3, 3): + total += 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2] + return total / count + + +def _encode_pair(tmp_path, apply_filter, encoder_id="avc_x264", is_rigaya=False): + """Encode the HDR10+ source twice: once baseline, once with apply_filter applied. + + Returns (baseline_pixels, filtered_pixels, width, height). + """ + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + if is_rigaya: + settings = NVEncCSettings(preset="performance", bitrate="1000k") + module_path = f"fastflix.encoders.{encoder_id}.command_builder" + expected_codec = "hevc" + else: + settings = x264Settings(preset="ultrafast", crf=28, pix_fmt="yuv420p") + module_path = f"fastflix.encoders.{encoder_id}.command_builder" + expected_codec = "h264" + + module = __import__(module_path, fromlist=["build"]) + work_path = tmp_path / "work" + work_path.mkdir(exist_ok=True) + + # Baseline + out_base = tmp_path / "baseline.mkv" + ff_base = create_fastflix( + source_path=source, + encoder_settings=settings.model_copy(), + output_path=out_base, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + run_commands(module.build(ff_base), work_path) + verify_output(out_base, expected_codec) + + # Filtered + out_filt = tmp_path / "filtered.mkv" + ff_filt = create_fastflix( + source_path=source, + encoder_settings=settings.model_copy(), + output_path=out_filt, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + apply_filter(ff_filt) + run_commands(module.build(ff_filt), work_path) + verify_output(out_filt, expected_codec) + + base_px, w, h = _get_frame_rgb(out_base) + filt_px, _, _ = _get_frame_rgb(out_filt) + return base_px, filt_px, w, h + + +# =========================================================================== +# Filter tests: Brightness +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,is_rigaya", + [ + pytest.param( + "avc_x264", + False, + id="x264", + marks=pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available"), + ), + pytest.param( + "nvencc_hevc", + True, + id="nvencc", + marks=pytest.mark.skipif(not NVENCC or SKIP_NVIDIA, reason="NVEncC not available"), + ), + ], +) +def test_filter_brightness(encoder_id, is_rigaya, tmp_path): + """Positive brightness makes pixels brighter (higher luma).""" + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "brightness", "0.3"), + encoder_id=encoder_id, + is_rigaya=is_rigaya, + ) + base_luma = _avg_luma(base_px, w, h) + filt_luma = _avg_luma(filt_px, w, h) + assert filt_luma > base_luma + 5, ( + f"Brightness filter should increase luma: base={base_luma:.1f}, filtered={filt_luma:.1f}" + ) + + +# =========================================================================== +# Filter tests: Vibrance (FFmpeg only — ʘ) +# =========================================================================== +@pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available") +def test_filter_vibrance(tmp_path): + """Positive vibrance increases color saturation (R-B spread widens for colorful pixels).""" + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "vibrance", "2.0"), + ) + + # Saturation proxy: average absolute difference between max and min channel per pixel + def avg_saturation(pixels, w, h): + total = 0.0 + count = w * h + for i in range(0, count * 3, 3): + total += max(pixels[i], pixels[i + 1], pixels[i + 2]) - min(pixels[i], pixels[i + 1], pixels[i + 2]) + return total / count + + base_sat = avg_saturation(base_px, w, h) + filt_sat = avg_saturation(filt_px, w, h) + assert filt_sat > base_sat + 1, f"Vibrance should increase saturation: base={base_sat:.1f}, filtered={filt_sat:.1f}" + + +# =========================================================================== +# Filter tests: Color Temperature (FFmpeg only — ʘ) +# =========================================================================== +@pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available") +def test_filter_color_temperature(tmp_path): + """Warm color temperature (low Kelvin) shifts pixels toward red, away from blue.""" + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "color_temperature", "2000"), + ) + base_r, _, base_b = _avg_rgb(base_px, w, h) + filt_r, _, filt_b = _avg_rgb(filt_px, w, h) + # Warm temp: R should increase relative to B + base_rb_diff = base_r - base_b + filt_rb_diff = filt_r - filt_b + assert filt_rb_diff > base_rb_diff + 3, ( + f"Warm color temp should shift R-B balance warmer: base R-B={base_rb_diff:.1f}, filtered R-B={filt_rb_diff:.1f}" + ) + + +# =========================================================================== +# Filter tests: Curves Preset +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,is_rigaya", + [ + pytest.param( + "avc_x264", + False, + id="x264", + marks=pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available"), + ), + pytest.param( + "nvencc_hevc", + True, + id="nvencc", + marks=pytest.mark.skipif(not NVENCC or SKIP_NVIDIA, reason="NVEncC not available"), + ), + ], +) +def test_filter_curves_darker(encoder_id, is_rigaya, tmp_path): + """Curves preset 'darker' reduces average luma.""" + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "curves_preset", "darker"), + encoder_id=encoder_id, + is_rigaya=is_rigaya, + ) + base_luma = _avg_luma(base_px, w, h) + filt_luma = _avg_luma(filt_px, w, h) + assert filt_luma < base_luma - 3, ( + f"Curves 'darker' should reduce luma: base={base_luma:.1f}, filtered={filt_luma:.1f}" + ) + + +# =========================================================================== +# Filter tests: Colorbalance (FFmpeg only — ʘ) +# =========================================================================== +@pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available") +def test_filter_colorbalance_warm_shadows(tmp_path): + """Warm Shadows colorbalance shifts R higher in dark pixel regions.""" + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "colorbalance", "colorbalance=rs=0.15:bs=-0.15"), + ) + # Average R channel in dark pixels (luma < 80) should increase + base_r_sum = filt_r_sum = 0 + total_dark = 0 + for i in range(0, w * h * 3, 3): + luma = 0.299 * base_px[i] + 0.587 * base_px[i + 1] + 0.114 * base_px[i + 2] + if luma < 80: + total_dark += 1 + base_r_sum += base_px[i] + filt_r_sum += filt_px[i] + assert total_dark > 0, "No dark pixels found in source" + base_avg_r = base_r_sum / total_dark + filt_avg_r = filt_r_sum / total_dark + assert filt_avg_r > base_avg_r + 1, ( + f"Warm Shadows should boost average R in dark pixels: base={base_avg_r:.1f}, filtered={filt_avg_r:.1f}" + ) + + +# =========================================================================== +# Filter tests: Unsharp Mask +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,is_rigaya", + [ + pytest.param( + "avc_x264", + False, + id="x264", + marks=pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available"), + ), + pytest.param( + "nvencc_hevc", + True, + id="nvencc", + marks=pytest.mark.skipif(not NVENCC or SKIP_NVIDIA, reason="NVEncC not available"), + ), + ], +) +def test_filter_unsharp(encoder_id, is_rigaya, tmp_path): + """Unsharp mask increases local contrast (pixel variance increases).""" + from fastflix.widgets.panels.advanced_panel import unsharp_presets + + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "unsharp", unsharp_presets["strong"]), + encoder_id=encoder_id, + is_rigaya=is_rigaya, + ) + + # Measure local contrast: average absolute difference between adjacent pixels + def local_contrast(pixels, w, h): + total = 0.0 + count = 0 + for row in range(h): + for col in range(w - 1): + i = (row * w + col) * 3 + j = i + 3 + total += abs(pixels[i] - pixels[j]) # R channel neighbor diff + count += 1 + return total / count if count else 0 + + base_contrast = local_contrast(base_px, w, h) + filt_contrast = local_contrast(filt_px, w, h) + assert filt_contrast > base_contrast, ( + f"Unsharp should increase local contrast: base={base_contrast:.2f}, filtered={filt_contrast:.2f}" + ) + + +# =========================================================================== +# Filter tests: Deflicker (FFmpeg only — ʘ) +# =========================================================================== +@pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available") +def test_filter_deflicker(tmp_path): + """Deflicker encodes successfully (temporal effect — verify encode completes).""" + from fastflix.widgets.panels.advanced_panel import deflicker_presets + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + work_path = tmp_path / "work" + work_path.mkdir() + output = tmp_path / "deflicker.mkv" + + ff = create_fastflix( + source_path=source, + encoder_settings=x264Settings(preset="ultrafast", crf=28, pix_fmt="yuv420p"), + output_path=output, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + ff.current_video.video_settings.deflicker = deflicker_presets["medium"] + module = __import__("fastflix.encoders.avc_x264.command_builder", fromlist=["build"]) + commands = module.build(ff) + cmd_str = commands[0].to_string() + assert "deflicker" in cmd_str, f"deflicker filter not in command: {cmd_str}" + run_commands(commands, work_path) + verify_output(output, "h264") + + +# =========================================================================== +# Filter tests: Pad / Letterbox +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,is_rigaya", + [ + pytest.param( + "avc_x264", + False, + id="x264", + marks=pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available"), + ), + pytest.param( + "nvencc_hevc", + True, + id="nvencc", + marks=pytest.mark.skipif(not NVENCC or SKIP_NVIDIA, reason="NVEncC not available"), + ), + ], +) +def test_filter_pad_letterbox(encoder_id, is_rigaya, tmp_path): + """Pad to 1:1 adds black bars — border pixels should be near-black.""" + + def apply_pad(ff): + ff.current_video.video_settings.pad_aspect = "1:1" + ff.current_video.video_settings.pad_color = "black" + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + if is_rigaya: + settings = NVEncCSettings(preset="performance", bitrate="1000k") + expected_codec = "hevc" + else: + settings = x264Settings(preset="ultrafast", crf=28, pix_fmt="yuv420p") + expected_codec = "h264" + + work_path = tmp_path / "work" + work_path.mkdir() + output = tmp_path / "padded.mkv" + + ff = create_fastflix( + source_path=source, + encoder_settings=settings, + output_path=output, + work_path=work_path, + is_rigaya=is_rigaya, + include_data=False, + include_attachments=False, + ) + apply_pad(ff) + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + run_commands(module.build(ff), work_path) + verify_output(output, expected_codec) + + pixels, w, h = _get_frame_rgb(output) + + # For 1:1 pad on a wider-than-tall source, black bars appear at top/bottom. + # For a taller-than-wide source, black bars at left/right. + # Either way, some border pixels should be near-black. + # Check top-left and bottom-right corner pixels. + corners = [(0, 0), (0, w - 1), (h - 1, 0), (h - 1, w - 1)] + black_corners = 0 + for row, col in corners: + off = (row * w + col) * 3 + r, g, b = pixels[off], pixels[off + 1], pixels[off + 2] + if r < 20 and g < 20 and b < 20: + black_corners += 1 + assert black_corners >= 2, ( + f"Pad to 1:1 should produce black bars — expected at least 2 near-black corners, got {black_corners}" + ) + + +# =========================================================================== +# Filter tests: LUT3D +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,is_rigaya", + [ + pytest.param( + "avc_x264", + False, + id="x264", + marks=pytest.mark.skipif(not has_ffmpeg_encoder("libx264"), reason="libx264 not available"), + ), + ], +) +def test_filter_lut3d(encoder_id, is_rigaya, tmp_path): + """LUT3D with R/B swap produces pixels with swapped red and blue channels.""" + lut_path = MEDIA_DIR / "test_color_shift.cube" + assert lut_path.exists(), f"Test LUT not found: {lut_path}" + + base_px, filt_px, w, h = _encode_pair( + tmp_path, + lambda ff: setattr(ff.current_video.video_settings, "lut3d_path", str(lut_path)), + encoder_id=encoder_id, + is_rigaya=is_rigaya, + ) + + tolerance = 30 + swap_confirmed = 0 + for row, col in sample_positions_5(h, w): + off = (row * w + col) * 3 + r_orig, g_orig, b_orig = base_px[off], base_px[off + 1], base_px[off + 2] + r_lut, g_lut, b_lut = filt_px[off], filt_px[off + 1], filt_px[off + 2] + if abs(r_lut - b_orig) <= tolerance and abs(b_lut - r_orig) <= tolerance and abs(g_lut - g_orig) <= tolerance: + swap_confirmed += 1 + + assert swap_confirmed >= 3, f"LUT3D R/B swap not detected: only {swap_confirmed}/5 sample points matched" diff --git a/tests/e2e/test_e2e_qsvencc_tune.py b/tests/e2e/test_e2e_qsvencc_tune.py new file mode 100644 index 00000000..005553d0 --- /dev/null +++ b/tests/e2e/test_e2e_qsvencc_tune.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +""" +E2E tests for QSVEncC --tune parameter support. + +These tests verify that the --tune flag is correctly wired through to +the generated commands and produces valid output when running on +hardware with Intel QSV support. + +Run locally: + uv run pytest tests/e2e/test_e2e_qsvencc_tune.py -v + SKIP_INTEL=1 uv run pytest tests/e2e -v # skip Intel tests + +Skipped on CI (GitHub Actions sets CI=true). +""" + +import pytest + +from fastflix.models.encode import QSVEncCAV1Settings, QSVEncCH264Settings, QSVEncCSettings + +from tests.e2e.conftest import ( + ON_CI, + QSVENCC, + SKIP_INTEL, + FFMPEG, + FFPROBE, + SOURCES, + create_fastflix, + run_commands, + verify_output, +) + +pytestmark = [pytest.mark.e2e] + +if ON_CI: + pytestmark.append(pytest.mark.skip(reason="E2E tests skipped on CI")) +if not FFMPEG or not FFPROBE: + pytestmark.append(pytest.mark.skip(reason="ffmpeg/ffprobe not found")) + + +TUNE_VALUES = ["hq", "ll", "ull", "lossless"] + + +def _skip_if_no_qsvencc(): + if not QSVENCC: + pytest.skip("QSVEncC not found") + if SKIP_INTEL: + pytest.skip("Intel tests skipped via SKIP_INTEL") + + +# =========================================================================== +# Test 1: QSVEncC HEVC --tune flag in generated commands +# =========================================================================== +@pytest.mark.parametrize("tune_value", TUNE_VALUES) +def test_qsvencc_hevc_tune_command(tune_value, tmp_path): + """QSVEncC HEVC encoder includes --tune flag with correct value in generated command.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"hevc_tune_{tune_value}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCSettings(preset="fastest", bitrate="1000k", tune=tune_value), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_hevc import command_builder + + commands = command_builder.build(fastflix) + assert commands, "QSVEncC HEVC returned no commands" + + cmd = commands[0].command + assert "--tune" in cmd, f"--tune not found in command: {cmd}" + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == tune_value, f"Expected tune={tune_value}, got {cmd[tune_idx + 1]}" + + +# =========================================================================== +# Test 2: QSVEncC AV1 --tune flag in generated commands +# =========================================================================== +@pytest.mark.parametrize("tune_value", TUNE_VALUES) +def test_qsvencc_av1_tune_command(tune_value, tmp_path): + """QSVEncC AV1 encoder includes --tune flag with correct value in generated command.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"av1_tune_{tune_value}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCAV1Settings(preset="fastest", bitrate="1000k", tune=tune_value), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_av1 import command_builder + + commands = command_builder.build(fastflix) + assert commands, "QSVEncC AV1 returned no commands" + + cmd = commands[0].command + assert "--tune" in cmd, f"--tune not found in command: {cmd}" + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == tune_value, f"Expected tune={tune_value}, got {cmd[tune_idx + 1]}" + + +# =========================================================================== +# Test 3: QSVEncC AVC --tune flag in generated commands +# =========================================================================== +@pytest.mark.parametrize("tune_value", TUNE_VALUES) +def test_qsvencc_avc_tune_command(tune_value, tmp_path): + """QSVEncC AVC encoder includes --tune flag with correct value in generated command.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"avc_tune_{tune_value}.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCH264Settings(preset="fastest", bitrate="1000k", tune=tune_value), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_avc import command_builder + + commands = command_builder.build(fastflix) + assert commands, "QSVEncC AVC returned no commands" + + cmd = commands[0].command + assert "--tune" in cmd, f"--tune not found in command: {cmd}" + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == tune_value, f"Expected tune={tune_value}, got {cmd[tune_idx + 1]}" + + +# =========================================================================== +# Test 4: QSVEncC --tune omitted when not set +# =========================================================================== +@pytest.mark.parametrize( + "encoder_id,settings_cls,codec", + [ + ("qsvencc_hevc", QSVEncCSettings, "hevc"), + ("qsvencc_av1", QSVEncCAV1Settings, "av1"), + ("qsvencc_avc", QSVEncCH264Settings, "h264"), + ], +) +def test_qsvencc_tune_omitted_when_none(encoder_id, settings_cls, codec, tmp_path): + """--tune is NOT in the command when tune is None (default).""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / f"{encoder_id}_no_tune.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=settings_cls(preset="fastest", bitrate="1000k"), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + module = __import__(f"fastflix.encoders.{encoder_id}.command_builder", fromlist=["build"]) + commands = module.build(fastflix) + assert commands + + cmd = commands[0].command + assert "--tune" not in cmd, f"--tune should not be present when tune is None: {cmd}" + + +# =========================================================================== +# Test 5: Full encode with --tune hq (HEVC) +# =========================================================================== +def test_qsvencc_hevc_tune_encode(tmp_path): + """QSVEncC HEVC with --tune hq produces valid output.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_tune_encode.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCSettings(preset="fastest", bitrate="1000k", tune="hq"), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_hevc import command_builder + + commands = command_builder.build(fastflix) + assert commands + + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + +# =========================================================================== +# Test 6: Full encode with --tune ll (AV1) +# =========================================================================== +def test_qsvencc_av1_tune_encode(tmp_path): + """QSVEncC AV1 with --tune ll produces valid output.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "av1_tune_encode.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCAV1Settings(preset="fastest", bitrate="1000k", tune="ll"), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_av1 import command_builder + + commands = command_builder.build(fastflix) + assert commands + + run_commands(commands, work_path) + verify_output(output_path, "av1") + + +# =========================================================================== +# Test 7: Full encode with --tune hq (AVC) +# =========================================================================== +def test_qsvencc_avc_tune_encode(tmp_path): + """QSVEncC AVC with --tune hq produces valid output.""" + _skip_if_no_qsvencc() + + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "avc_tune_encode.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=QSVEncCH264Settings(preset="fastest", bitrate="1000k", tune="hq"), + output_path=output_path, + work_path=work_path, + is_rigaya=True, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.qsvencc_avc import command_builder + + commands = command_builder.build(fastflix) + assert commands + + run_commands(commands, work_path) + verify_output(output_path, "h264") diff --git a/tests/e2e/test_e2e_videotoolbox.py b/tests/e2e/test_e2e_videotoolbox.py new file mode 100644 index 00000000..0a9e7c26 --- /dev/null +++ b/tests/e2e/test_e2e_videotoolbox.py @@ -0,0 +1,607 @@ +# -*- coding: utf-8 -*- +""" +E2E tests for VideoToolbox (Apple) encoders — HEVC and H264. + +These tests verify that commands are correctly generated and, on macOS +hardware with VideoToolbox support, produce valid output files. + +Run locally (macOS only): + uv run pytest tests/e2e/test_e2e_videotoolbox.py -v + SKIP_MAC=1 uv run pytest tests/e2e -v # skip Mac tests + +Skipped on CI (GitHub Actions sets CI=true). +Skipped on non-Mac platforms (VideoToolbox is macOS-only). +""" + +import pytest + +from fastflix.models.encode import H264VideoToolboxSettings, HEVCVideoToolboxSettings + +from tests.e2e.conftest import ( + FFMPEG, + FFPROBE, + ON_CI, + SKIP_MAC, + SOURCES, + create_fastflix, + has_ffmpeg_encoder, + run_commands, + verify_output, +) + +pytestmark = [pytest.mark.e2e] + +if ON_CI: + pytestmark.append(pytest.mark.skip(reason="E2E tests skipped on CI")) +if not FFMPEG or not FFPROBE: + pytestmark.append(pytest.mark.skip(reason="ffmpeg/ffprobe not found")) + + +def _skip_if_no_videotoolbox(encoder_name: str): + if not has_ffmpeg_encoder(encoder_name): + pytest.skip(f"{encoder_name} not available in this FFmpeg build") + if SKIP_MAC: + pytest.skip("Mac tests skipped via SKIP_MAC") + + +# =========================================================================== +# HEVC VideoToolbox +# =========================================================================== + + +class TestHEVCVideoToolbox: + """HEVC VideoToolbox encoder E2E tests.""" + + def _skip(self): + _skip_if_no_videotoolbox("hevc_videotoolbox") + + # -- Command generation (no hardware needed, just needs FFmpeg awareness) -- + + def test_command_profile_auto(self, tmp_path): + """Profile Auto omits -profile:v from generated command.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_auto.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=0, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert commands + cmd = commands[0].command + assert "-profile:v" not in cmd + assert "hevc_videotoolbox" in cmd + + def test_command_profile_main(self, tmp_path): + """Profile Main emits -profile:v main.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_main.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=1, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + cmd = commands[0].command + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "main" + + def test_command_profile_main10(self, tmp_path): + """Profile Main10 emits -profile:v main10.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_main10.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=2, q=50, pix_fmt="p010le"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + cmd = commands[0].command + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "main10" + + def test_command_bitrate_two_pass(self, tmp_path): + """Bitrate mode produces two-pass commands.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_bitrate.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="3000k"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert len(commands) == 2 + assert "-pass" in commands[0].command + assert "-pass" in commands[1].command + assert "-b:v" in commands[0].command + assert "-b:v" in commands[1].command + + def test_command_boolean_flags(self, tmp_path): + """Boolean options (allow_sw, realtime, etc.) appear as 'true'/'false'.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_bools.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(q=50, allow_sw=True, realtime=True), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + cmd = command_builder.build(fastflix)[0].command + assert cmd[cmd.index("-allow_sw") + 1] == "true" + assert cmd[cmd.index("-realtime") + 1] == "true" + assert cmd[cmd.index("-require_sw") + 1] == "false" + + # -- Full encode (needs VideoToolbox hardware) -- + + def test_encode_quality_auto_profile(self, tmp_path): + """Encode with Auto profile and constant quality produces valid HEVC output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_encode_auto.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=0, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + def test_encode_quality_main_profile(self, tmp_path): + """Encode with Main profile and constant quality.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_encode_main.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=1, q=50, pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + def test_encode_quality_main10_profile(self, tmp_path): + """Encode with Main10 profile (10-bit) and constant quality.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_encode_main10.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(profile=2, q=50, pix_fmt="p010le"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + def test_encode_bitrate_mode(self, tmp_path): + """Two-pass bitrate encode produces valid HEVC output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_encode_bitrate.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="3000k"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert len(commands) == 2 + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + def test_encode_allow_sw(self, tmp_path): + """Encode with allow_sw=True (software fallback) produces valid output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_allow_sw.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(q=50, allow_sw=True), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + def test_encode_mkv_output(self, tmp_path): + """HEVC VideoToolbox can encode to MKV container.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "hevc_vtb_encode.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=HEVCVideoToolboxSettings(q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.hevc_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "hevc") + + +# =========================================================================== +# H264 VideoToolbox +# =========================================================================== + + +class TestH264VideoToolbox: + """H264 VideoToolbox encoder E2E tests.""" + + def _skip(self): + _skip_if_no_videotoolbox("h264_videotoolbox") + + # -- Command generation -- + + def test_command_profile_auto(self, tmp_path): + """Profile Auto omits -profile:v from generated command.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_auto.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(profile=0, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert commands + cmd = commands[0].command + assert "-profile:v" not in cmd + assert "h264_videotoolbox" in cmd + + def test_command_profile_baseline(self, tmp_path): + """Profile baseline emits -profile:v baseline.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_baseline.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(profile=1, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + cmd = command_builder.build(fastflix)[0].command + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "baseline" + + def test_command_profile_high(self, tmp_path): + """Profile high emits -profile:v high.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_high.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(profile=3, q=50), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + cmd = command_builder.build(fastflix)[0].command + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "high" + + def test_command_bitrate_two_pass(self, tmp_path): + """Bitrate mode produces two-pass commands.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_bitrate.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(q=None, bitrate="3000k"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert len(commands) == 2 + + # -- Full encode -- + + def test_encode_quality_auto_profile(self, tmp_path): + """Encode with Auto profile produces valid H264 output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_encode_auto.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(profile=0, q=50, pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "h264") + + def test_encode_quality_high_profile(self, tmp_path): + """Encode with High profile produces valid H264 output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_encode_high.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(profile=3, q=50, pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "h264") + + def test_encode_bitrate_mode(self, tmp_path): + """Two-pass bitrate encode produces valid H264 output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_encode_bitrate.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(q=None, bitrate="3000k", pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + assert len(commands) == 2 + run_commands(commands, work_path) + verify_output(output_path, "h264") + + def test_encode_allow_sw(self, tmp_path): + """Encode with allow_sw=True produces valid output.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_allow_sw.mp4" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(q=50, allow_sw=True, pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "h264") + + def test_encode_mkv_output(self, tmp_path): + """H264 VideoToolbox can encode to MKV container.""" + self._skip() + source = SOURCES["hdr10plus"] + if not source.exists(): + pytest.skip("HDR10+ test source not found") + + output_path = tmp_path / "h264_vtb_encode.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + fastflix = create_fastflix( + source_path=source, + encoder_settings=H264VideoToolboxSettings(q=50, pix_fmt="yuv420p"), + output_path=output_path, + work_path=work_path, + include_data=False, + include_attachments=False, + ) + + from fastflix.encoders.h264_videotoolbox import command_builder + + commands = command_builder.build(fastflix) + run_commands(commands, work_path) + verify_output(output_path, "h264") diff --git a/tests/encoders/test_aom_av1_command_builder.py b/tests/encoders/test_aom_av1_command_builder.py index feb23700..2084d060 100644 --- a/tests/encoders/test_aom_av1_command_builder.py +++ b/tests/encoders/test_aom_av1_command_builder.py @@ -161,6 +161,37 @@ def test_aom_av1_row_mt_disabled(): assert "-row-mt" not in cmd +def test_aom_av1_two_pass_crf(): + """Test 2-pass CRF encoding produces two commands.""" + result = _build_with_settings(single_pass=False) + assert len(result) == 2 + cmd1 = result[0].command + cmd2 = result[1].command + # First pass: CRF + pass 1 + null output + assert "-crf" in cmd1 + assert "26" in cmd1 + assert "-pass" in cmd1 + assert "1" in cmd1 + assert "-passlogfile" in cmd1 + assert "-an" in cmd1 + # Second pass: CRF + pass 2 + real output + assert "-crf" in cmd2 + assert "26" in cmd2 + assert "-pass" in cmd2 + assert "2" in cmd2 + assert "-passlogfile" in cmd2 + + +def test_aom_av1_single_pass_crf(): + """Test single-pass CRF (default) produces one command.""" + result = _build_with_settings(single_pass=True) + assert len(result) == 1 + cmd = result[0].command + assert "-crf" in cmd + assert "-pass" not in cmd + assert "-passlogfile" not in cmd + + def test_aom_av1_all_elements_are_strings(): """Test that all command elements are strings.""" result = _build_with_settings() diff --git a/tests/encoders/test_audio.py b/tests/encoders/test_audio.py index 3f536af2..ccee118c 100644 --- a/tests/encoders/test_audio.py +++ b/tests/encoders/test_audio.py @@ -228,3 +228,40 @@ def test_build_audio_with_strict_codecs(sample_audio_tracks): assert "-strict" in result assert "-2" in result assert _has_consecutive(result, "-strict", "-2") + + +def test_build_audio_with_conversion_profile(sample_audio_tracks): + """Test build_audio emits -profile:X when conversion_profile is set.""" + sample_audio_tracks[0].conversion_codec = "aac" + sample_audio_tracks[0].conversion_profile = "aac_he" + sample_audio_tracks[0].conversion_bitrate = "64k" + + result = build_audio(sample_audio_tracks) + + assert _has_consecutive(result, "-c:0", "aac") + assert _has_consecutive(result, "-profile:0", "aac_he") + assert _has_consecutive(result, "-b:0", "64k") + + +def test_build_audio_with_conversion_profile_he_v2(sample_audio_tracks): + """Test build_audio emits -profile:X aac_he_v2.""" + sample_audio_tracks[0].conversion_codec = "libfdk_aac" + sample_audio_tracks[0].conversion_profile = "aac_he_v2" + sample_audio_tracks[0].conversion_bitrate = "48k" + + result = build_audio(sample_audio_tracks) + + assert _has_consecutive(result, "-c:0", "libfdk_aac") + assert _has_consecutive(result, "-profile:0", "aac_he_v2") + + +def test_build_audio_no_profile_when_none(sample_audio_tracks): + """Test build_audio does NOT emit -profile:X when conversion_profile is None.""" + sample_audio_tracks[0].conversion_codec = "aac" + sample_audio_tracks[0].conversion_profile = None + sample_audio_tracks[0].conversion_bitrate = "128k" + + result = build_audio(sample_audio_tracks) + + assert _has_consecutive(result, "-c:0", "aac") + assert "-profile:0" not in result diff --git a/tests/encoders/test_encc_helpers.py b/tests/encoders/test_encc_helpers.py index 47464bba..6acd616c 100644 --- a/tests/encoders/test_encc_helpers.py +++ b/tests/encoders/test_encc_helpers.py @@ -8,7 +8,9 @@ rigaya_avformat_reader, rigaya_auto_options, rigaya_trim_or_seek, - _parse_frame_rate, + rigaya_extra_options, + RIGAYA_DENOISE_MAP, + parse_frame_rate, pa_builder, get_stream_pos, build_audio, @@ -521,40 +523,40 @@ def test_build_subtitle_with_4k_scaling(sample_subtitle_tracks): assert "--vpp-subburn" in result and "track=1,scale=2.0" in result -# --- _parse_frame_rate tests --- +# --- parse_frame_rate tests --- -def test_parse_frame_rate_rational(): +def testparse_frame_rate_rational(): """Test parsing a rational frame rate string like '24000/1001'.""" - result = _parse_frame_rate("24000/1001") + result = parse_frame_rate("24000/1001") assert result == pytest.approx(23.976, rel=1e-3) -def test_parse_frame_rate_integer_string(): +def testparse_frame_rate_integer_string(): """Test parsing a plain integer frame rate string.""" - result = _parse_frame_rate("30") + result = parse_frame_rate("30") assert result == 30.0 -def test_parse_frame_rate_float_string(): +def testparse_frame_rate_float_string(): """Test parsing a plain float frame rate string.""" - result = _parse_frame_rate("29.97") + result = parse_frame_rate("29.97") assert result == pytest.approx(29.97) -def test_parse_frame_rate_empty(): +def testparse_frame_rate_empty(): """Test parsing an empty string returns None.""" - assert _parse_frame_rate("") is None + assert parse_frame_rate("") is None -def test_parse_frame_rate_invalid(): +def testparse_frame_rate_invalid(): """Test parsing an invalid string returns None.""" - assert _parse_frame_rate("abc") is None + assert parse_frame_rate("abc") is None -def test_parse_frame_rate_zero_denominator(): +def testparse_frame_rate_zero_denominator(): """Test parsing a rational with zero denominator returns None.""" - assert _parse_frame_rate("24000/0") is None + assert parse_frame_rate("24000/0") is None # --- rigaya_trim_or_seek tests --- @@ -732,3 +734,305 @@ def test_rigaya_trim_or_seek_exact_mode_no_frame_rate_fallback(encc_fastflix_ins assert "--seek" in result assert "--seekto" in result assert "--trim" not in result + + +# --- rigaya_extra_options tests --- + + +def test_rigaya_extra_options_equalizer_all(encc_fastflix_instance): + """Test --vpp-tweak with all four equalizer values set.""" + video = encc_fastflix_instance.current_video + video.video_settings.brightness = "0.1" + video.video_settings.contrast = "1.5" + video.video_settings.saturation = "1.2" + video.video_settings.gamma = "0.9" + result = rigaya_extra_options(video) + assert "--vpp-tweak" in result + tweak_value = result[result.index("--vpp-tweak") + 1] + assert "brightness=0.1" in tweak_value + assert "contrast=1.5" in tweak_value + assert "saturation=1.2" in tweak_value + assert "gamma=0.9" in tweak_value + + +def test_rigaya_extra_options_equalizer_partial(encc_fastflix_instance): + """Test --vpp-tweak with only some values set.""" + video = encc_fastflix_instance.current_video + video.video_settings.brightness = "0.2" + video.video_settings.gamma = "2.0" + result = rigaya_extra_options(video) + assert "--vpp-tweak" in result + tweak_value = result[result.index("--vpp-tweak") + 1] + assert "brightness=0.2" in tweak_value + assert "gamma=2.0" in tweak_value + assert "contrast" not in tweak_value + assert "saturation" not in tweak_value + + +def test_rigaya_extra_options_equalizer_defaults_skipped(encc_fastflix_instance): + """Test that default values produce no --vpp-tweak.""" + video = encc_fastflix_instance.current_video + video.video_settings.brightness = "0" + video.video_settings.contrast = "1.0" + video.video_settings.saturation = "1" + video.video_settings.gamma = "1.0" + result = rigaya_extra_options(video) + assert "--vpp-tweak" not in result + + +def test_rigaya_extra_options_equalizer_none(encc_fastflix_instance): + """Test that None values produce no --vpp-tweak.""" + video = encc_fastflix_instance.current_video + result = rigaya_extra_options(video) + assert "--vpp-tweak" not in result + + +def test_rigaya_extra_options_equalizer_clamping(encc_fastflix_instance): + """Test that values are clamped to rigaya ranges.""" + video = encc_fastflix_instance.current_video + video.video_settings.brightness = "5.0" # exceeds 1.0 + video.video_settings.saturation = "-1.0" # below 0.0 + result = rigaya_extra_options(video) + assert "--vpp-tweak" in result + tweak_value = result[result.index("--vpp-tweak") + 1] + assert "brightness=1.0" in tweak_value + assert "saturation=0.0" in tweak_value + + +def test_rigaya_extra_options_denoise_nlmeans(encc_fastflix_instance): + """Test denoise mapping for nlmeans presets.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "nlmeans=s=1.0:p=3:r=9" + result = rigaya_extra_options(video) + assert "--vpp-nlmeans" in result + assert "sigma=1.0,h=1.0,patch=3,search=9" in result + + +def test_rigaya_extra_options_denoise_atadenoise(encc_fastflix_instance): + """Test denoise mapping for atadenoise -> knn.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "atadenoise=0a=0.02:0b=0.04:1a=0.02:1b=0.04:2a=0.02:2b=0.04:s=9" + result = rigaya_extra_options(video) + assert "--vpp-knn" in result + + +def test_rigaya_extra_options_denoise_hqdn3d(encc_fastflix_instance): + """Test denoise mapping for hqdn3d -> pmd.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "hqdn3d=luma_spatial=4:chroma_spatial=3:luma_tmp=6:chroma_tmp=4.5" + result = rigaya_extra_options(video) + assert "--vpp-pmd" in result + + +def test_rigaya_extra_options_denoise_vaguedenoiser(encc_fastflix_instance): + """Test denoise mapping for vaguedenoiser -> pmd.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "vaguedenoiser=threshold=3:method=soft:nsteps=5" + result = rigaya_extra_options(video) + assert "--vpp-pmd" in result + + +def test_rigaya_extra_options_denoise_all_presets_mapped(): + """Test that all 15 known denoise presets have mappings (12 original + 3 nlmeans_opencl).""" + assert len(RIGAYA_DENOISE_MAP) == 15 + + +def test_rigaya_extra_options_denoise_unknown(encc_fastflix_instance): + """Test that unknown denoise strings are skipped.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "unknown_filter=strength=5" + result = rigaya_extra_options(video) + assert "--vpp-nlmeans" not in result + assert "--vpp-knn" not in result + assert "--vpp-pmd" not in result + + +def test_rigaya_extra_options_deblock_weak(encc_fastflix_instance): + """Test deblock weak mapping.""" + video = encc_fastflix_instance.current_video + video.video_settings.deblock = "weak" + result = rigaya_extra_options(video) + assert "--vpp-deblock" in result + assert "strength=30" in result + + +def test_rigaya_extra_options_deblock_strong(encc_fastflix_instance): + """Test deblock strong mapping.""" + video = encc_fastflix_instance.current_video + video.video_settings.deblock = "strong" + result = rigaya_extra_options(video) + assert "--vpp-deblock" in result + assert "strength=60" in result + + +def test_rigaya_extra_options_output_fps(encc_fastflix_instance): + """Test output FPS mapping.""" + video = encc_fastflix_instance.current_video + video.video_settings.output_fps = "24" + result = rigaya_extra_options(video) + assert "--vpp-fps" in result + assert "fps=24" in result + + +def test_rigaya_extra_options_video_track_title(encc_fastflix_instance): + """Test video track title mapping.""" + video = encc_fastflix_instance.current_video + video.video_settings.video_track_title = "My Video" + result = rigaya_extra_options(video) + assert "--video-metadata" in result + assert "title=My Video" in result + + +def test_rigaya_extra_options_combined(encc_fastflix_instance): + """Test multiple features combined in a single call.""" + video = encc_fastflix_instance.current_video + video.video_settings.brightness = "0.1" + video.video_settings.gamma = "1.5" + video.video_settings.denoise = "nlmeans=s=10.0:p=13:r=25" + video.video_settings.deblock = "strong" + video.video_settings.output_fps = "30" + video.video_settings.video_track_title = "Test" + result = rigaya_extra_options(video) + assert "--vpp-tweak" in result + assert "--vpp-nlmeans" in result + assert "--vpp-deblock" in result + assert "--vpp-fps" in result + assert "--video-metadata" in result + + +def test_rigaya_extra_options_sharpen(encc_fastflix_instance): + """Test that sharpen generates --vpp-unsharp with correct parameters.""" + video = encc_fastflix_instance.current_video + video.video_settings.sharpen = "0.7" + result = rigaya_extra_options(video) + assert "--vpp-unsharp" in result + idx = result.index("--vpp-unsharp") + assert "radius=3,weight=0.7" in result[idx + 1] + + +def test_rigaya_extra_options_sharpen_zero(encc_fastflix_instance): + """Test that sharpen value of 0 does not add --vpp-unsharp.""" + video = encc_fastflix_instance.current_video + video.video_settings.sharpen = "0" + result = rigaya_extra_options(video) + assert "--vpp-unsharp" not in result + + +def test_rigaya_extra_options_sharpen_clamped(encc_fastflix_instance): + """Test that sharpen value is clamped to 1.0 max.""" + video = encc_fastflix_instance.current_video + video.video_settings.sharpen = "1.5" + result = rigaya_extra_options(video) + assert "--vpp-unsharp" in result + idx = result.index("--vpp-unsharp") + assert "radius=3,weight=1.0" in result[idx + 1] + + +def test_rigaya_extra_options_gop_length(encc_fastflix_instance): + """Test that gop_length generates --gop-len.""" + video = encc_fastflix_instance.current_video + video.video_settings.gop_length = 250 + result = rigaya_extra_options(video) + assert "--gop-len" in result + idx = result.index("--gop-len") + assert result[idx + 1] == "250" + + +def test_rigaya_extra_options_gop_length_none(encc_fastflix_instance): + """Test that no gop_length does not add --gop-len.""" + video = encc_fastflix_instance.current_video + video.video_settings.gop_length = None + result = rigaya_extra_options(video) + assert "--gop-len" not in result + + +def test_rigaya_extra_options_curves_preset(encc_fastflix_instance): + """Test that curves_preset generates --vpp-curves.""" + video = encc_fastflix_instance.current_video + video.video_settings.curves_preset = "vintage" + result = rigaya_extra_options(video) + assert "--vpp-curves" in result + idx = result.index("--vpp-curves") + assert result[idx + 1] == "preset=vintage" + + +def test_rigaya_extra_options_curves_preset_none(encc_fastflix_instance): + """Test that no curves_preset does not add --vpp-curves.""" + video = encc_fastflix_instance.current_video + video.video_settings.curves_preset = None + result = rigaya_extra_options(video) + assert "--vpp-curves" not in result + + +def test_rigaya_extra_options_lut3d(encc_fastflix_instance): + """Test that lut3d_path generates --vpp-colorspace with lut3d.""" + video = encc_fastflix_instance.current_video + video.video_settings.lut3d_path = "/path/to/my.cube" + result = rigaya_extra_options(video) + assert "--vpp-colorspace" in result + idx = result.index("--vpp-colorspace") + assert "lut3d=/path/to/my.cube" in result[idx + 1] + assert "lut3d_interp=tetrahedral" in result[idx + 1] + + +def test_rigaya_extra_options_lut3d_none(encc_fastflix_instance): + """Test that no lut3d_path does not add --vpp-colorspace.""" + video = encc_fastflix_instance.current_video + video.video_settings.lut3d_path = None + result = rigaya_extra_options(video) + assert "--vpp-colorspace" not in result + + +def test_rigaya_extra_options_unsharp_preset(encc_fastflix_instance): + """Test that unsharp preset generates --vpp-unsharp with correct parameters.""" + video = encc_fastflix_instance.current_video + video.video_settings.unsharp = "unsharp=5:5:1.0:5:5:0.5" + result = rigaya_extra_options(video) + assert "--vpp-unsharp" in result + idx = result.index("--vpp-unsharp") + assert "radius=3,weight=0.6" in result[idx + 1] + + +def test_rigaya_extra_options_unsharp_overrides_sharpen(encc_fastflix_instance): + """Test that when both unsharp and sharpen are set, unsharp takes priority.""" + video = encc_fastflix_instance.current_video + video.video_settings.unsharp = "unsharp=5:5:0.5:5:5:0.0" + video.video_settings.sharpen = "0.8" + result = rigaya_extra_options(video) + # Should only have one --vpp-unsharp (from unsharp, not sharpen) + count = result.count("--vpp-unsharp") + assert count == 1 + idx = result.index("--vpp-unsharp") + assert "radius=3,weight=0.3" in result[idx + 1] + + +def test_rigaya_extra_options_pad_16_9(encc_fastflix_instance): + """Test that pad aspect 16:9 generates --vpp-pad for 4:3 source.""" + video = encc_fastflix_instance.current_video + # Set up a 4:3 source (1440x1080) via streams + video.streams.video[0].width = 1440 + video.streams.video[0].height = 1080 + video.video_settings.pad_aspect = "16:9" + result = rigaya_extra_options(video) + assert "--vpp-pad" in result + idx = result.index("--vpp-pad") + # 1080 * 16/9 = 1920, pad_left = (1920-1440)/2 = 240 + assert "240,0,240,0" in result[idx + 1] + + +def test_rigaya_extra_options_pad_none(encc_fastflix_instance): + """Test that no pad_aspect does not add --vpp-pad.""" + video = encc_fastflix_instance.current_video + video.video_settings.pad_aspect = None + result = rigaya_extra_options(video) + assert "--vpp-pad" not in result + + +def test_rigaya_extra_options_nlmeans_opencl(encc_fastflix_instance): + """Test that nlmeans_opencl denoise maps to rigaya --vpp-nlmeans.""" + video = encc_fastflix_instance.current_video + video.video_settings.denoise = "nlmeans_opencl=s=1.0:p=3:r=9" + result = rigaya_extra_options(video) + assert "--vpp-nlmeans" in result + idx = result.index("--vpp-nlmeans") + assert "sigma=1.0,h=1.0,patch=3,search=9" in result[idx + 1] diff --git a/tests/encoders/test_h264_videotoolbox_command_builder.py b/tests/encoders/test_h264_videotoolbox_command_builder.py new file mode 100644 index 00000000..b69844f3 --- /dev/null +++ b/tests/encoders/test_h264_videotoolbox_command_builder.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from fastflix.encoders.h264_videotoolbox.command_builder import build +from fastflix.encoders.common.helpers import null +from fastflix.models.encode import H264VideoToolboxSettings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def _make_fastflix(encoder_settings, video_settings=None): + """Create a FastFlix instance for H264 VideoToolbox testing.""" + return create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings) + + +# --------------------------------------------------------------------------- +# Profile mapping — the fix for #741 +# --------------------------------------------------------------------------- +def test_profile_auto_omitted(): + """Profile 'Auto' (index 0) must NOT emit -profile:v — FFmpeg has no H264 profile 0.""" + fastflix = _make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=0, q=50)) + cmd = build(fastflix)[0].command + + assert "-profile:v" not in cmd + + +def test_profile_baseline(): + """Profile index 1 → -profile:v baseline.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=1, q=50)))[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "baseline" + + +def test_profile_main(): + """Profile index 2 → -profile:v main.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=2, q=50)))[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "main" + + +def test_profile_high(): + """Profile index 3 → -profile:v high.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=3, q=50)))[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "high" + + +def test_profile_extended(): + """Profile index 4 → -profile:v extended.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=4, q=50)))[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "extended" + + +def test_profile_unknown_index_omitted(): + """An index outside the known map (e.g. 99) should NOT emit -profile:v.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(profile=99, q=50)))[0].command + + assert "-profile:v" not in cmd + + +# --------------------------------------------------------------------------- +# Quality (constant-Q) mode +# --------------------------------------------------------------------------- +def test_quality_mode_single_pass(): + """Constant quality mode produces exactly one Command.""" + result = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=35, bitrate=None))) + + assert len(result) == 1 + assert result[0].name == "Single pass constant quality" + + +def test_quality_mode_q_value(): + """-q:v must carry the stringified quality value.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=80, bitrate=None)))[0].command + + idx = cmd.index("-q:v") + assert cmd[idx + 1] == "80" + + +def test_quality_mode_boundary_low(): + """Quality value 1 (lowest = best quality) is accepted.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=1, bitrate=None)))[0].command + + idx = cmd.index("-q:v") + assert cmd[idx + 1] == "1" + + +def test_quality_mode_boundary_high(): + """Quality value 100 (highest = worst quality) is accepted.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=100, bitrate=None)))[0].command + + idx = cmd.index("-q:v") + assert cmd[idx + 1] == "100" + + +def test_quality_mode_no_bitrate_flags(): + """Constant-Q must NOT contain -b:v or -pass flags.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50, bitrate=None)))[0].command + + assert "-b:v" not in cmd + assert "-pass" not in cmd + + +# --------------------------------------------------------------------------- +# Bitrate (two-pass) mode +# --------------------------------------------------------------------------- +def test_bitrate_mode_two_passes(): + """Bitrate mode must produce exactly two Commands.""" + result = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=None, bitrate="4000k"))) + + assert len(result) == 2 + assert "First pass" in result[0].name + assert "Second pass" in result[1].name + + +def test_bitrate_mode_pass_flags(): + """Each pass carries correct -pass value.""" + result = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=None, bitrate="4000k"))) + + cmd1 = result[0].command + assert cmd1[cmd1.index("-pass") + 1] == "1" + cmd2 = result[1].command + assert cmd2[cmd2.index("-pass") + 1] == "2" + + +def test_bitrate_mode_bitrate_value(): + """-b:v must carry the exact bitrate string in both passes.""" + result = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=None, bitrate="1800k"))) + + for cmd_obj in result: + cmd = cmd_obj.command + idx = cmd.index("-b:v") + assert cmd[idx + 1] == "1800k" + + +def test_bitrate_mode_first_pass_null(): + """First pass sends output to null device and skips audio.""" + cmd1 = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=None, bitrate="4000k")))[0].command + + assert null in cmd1 + assert "-an" in cmd1 + + +def test_bitrate_mode_passlogfile_shared(): + """Both passes must reference the same -passlogfile path.""" + with mock.patch("fastflix.encoders.h264_videotoolbox.command_builder.secrets.token_hex", return_value="cafe0123"): + result = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=None, bitrate="4000k"))) + + log1 = result[0].command[result[0].command.index("-passlogfile") + 1] + log2 = result[1].command[result[1].command.index("-passlogfile") + 1] + assert log1 == log2 + assert "cafe0123" in log1 + + +# --------------------------------------------------------------------------- +# Boolean options +# --------------------------------------------------------------------------- +def test_booleans_all_false(): + """Default booleans must emit 'false'.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50)))[0].command + + for flag in ("-allow_sw", "-require_sw", "-realtime", "-frames_before", "-frames_after"): + idx = cmd.index(flag) + assert cmd[idx + 1] == "false", f"{flag} should be 'false'" + + +def test_booleans_all_true(): + """All booleans True must emit 'true'.""" + cmd = build( + _make_fastflix( + encoder_settings=H264VideoToolboxSettings( + q=50, allow_sw=True, require_sw=True, realtime=True, frames_before=True, frames_after=True + ) + ) + )[0].command + + for flag in ("-allow_sw", "-require_sw", "-realtime", "-frames_before", "-frames_after"): + idx = cmd.index(flag) + assert cmd[idx + 1] == "true", f"{flag} should be 'true'" + + +# --------------------------------------------------------------------------- +# Extra / custom FFmpeg options +# --------------------------------------------------------------------------- +def test_extra_options_quality(): + """Custom extra options are appended in quality mode.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50, extra="-tag:v avc1")))[0].command + + assert "-tag:v" in cmd + assert "avc1" in cmd + + +def test_extra_both_passes_bitrate(): + """Extra appears in BOTH passes when extra_both_passes is True.""" + result = build( + _make_fastflix( + encoder_settings=H264VideoToolboxSettings( + q=None, bitrate="4000k", extra="-tag:v avc1", extra_both_passes=True + ) + ) + ) + + assert "-tag:v" in result[0].command + assert "-tag:v" in result[1].command + + +def test_extra_only_second_pass_by_default(): + """Without extra_both_passes, first pass must NOT have extra options.""" + result = build( + _make_fastflix( + encoder_settings=H264VideoToolboxSettings( + q=None, bitrate="4000k", extra="-tag:v avc1", extra_both_passes=False + ) + ) + ) + + assert "-tag:v" not in result[0].command + assert "-tag:v" in result[1].command + + +# --------------------------------------------------------------------------- +# Encoder identification +# --------------------------------------------------------------------------- +def test_encoder_name(): + """Command must contain h264_videotoolbox as the encoder.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50)))[0].command + + assert "h264_videotoolbox" in cmd + + +def test_command_exe(): + """All commands must declare exe='ffmpeg'.""" + for cmd_obj in build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50))): + assert cmd_obj.exe == "ffmpeg" + + +# --------------------------------------------------------------------------- +# Pixel format +# --------------------------------------------------------------------------- +def test_default_pix_fmt(): + """H264 VideoToolbox defaults to yuv420p (8-bit).""" + assert H264VideoToolboxSettings().pix_fmt == "yuv420p" + + +def test_pix_fmt_in_command(): + """The pix_fmt must appear in the generated command.""" + cmd = build(_make_fastflix(encoder_settings=H264VideoToolboxSettings(q=50, pix_fmt="yuv420p")))[0].command + + idx = cmd.index("-pix_fmt") + assert cmd[idx + 1] == "yuv420p" + + +# --------------------------------------------------------------------------- +# Start/end time +# --------------------------------------------------------------------------- +def test_start_end_time(): + """Start and end times must be stringified in the command.""" + fastflix = _make_fastflix( + encoder_settings=H264VideoToolboxSettings(q=50), + video_settings=VideoSettings(start_time=2.5, end_time=15.0, remove_hdr=False, maxrate=None, bufsize=None), + ) + cmd = build(fastflix)[0].command + + assert "-ss" in cmd + assert "2.5" in cmd + assert "-to" in cmd + assert "15.0" in cmd + + +# --------------------------------------------------------------------------- +# All-strings regression guard +# --------------------------------------------------------------------------- +def test_all_elements_are_strings_quality(): + """Every element in quality-mode command must be a string.""" + fastflix = _make_fastflix( + encoder_settings=H264VideoToolboxSettings(profile=3, q=50, allow_sw=True), + video_settings=VideoSettings( + start_time=5.0, end_time=30.0, remove_hdr=False, maxrate=8000, bufsize=16000, video_title="Test" + ), + ) + for i, el in enumerate(build(fastflix)[0].command): + assert isinstance(el, str), f"Element at index {i} is {type(el).__name__}: {el!r}" + + +def test_all_elements_are_strings_bitrate(): + """Every element in both bitrate-mode commands must be a string.""" + fastflix = _make_fastflix( + encoder_settings=H264VideoToolboxSettings(profile=2, q=None, bitrate="4000k"), + video_settings=VideoSettings(start_time=1.0, end_time=10.0, remove_hdr=False, maxrate=None, bufsize=None), + ) + for cmd_obj in build(fastflix): + for i, el in enumerate(cmd_obj.command): + assert isinstance(el, str), f"Element at index {i} is {type(el).__name__}: {el!r}" + + +# --------------------------------------------------------------------------- +# Model defaults +# --------------------------------------------------------------------------- +def test_settings_defaults(): + """Verify sensible defaults on the model.""" + s = H264VideoToolboxSettings() + assert s.profile == 0 + assert s.q == 50 + assert s.bitrate is None + assert s.pix_fmt == "yuv420p" + assert s.allow_sw is False + assert s.require_sw is False + assert s.realtime is False + assert s.frames_before is False + assert s.frames_after is False + + +# --------------------------------------------------------------------------- +# Import correctness (regression for wrong import) +# --------------------------------------------------------------------------- +def test_uses_correct_settings_type(): + """h264_videotoolbox must import H264VideoToolboxSettings, not HEVCVideoToolboxSettings.""" + import fastflix.encoders.h264_videotoolbox.command_builder as mod + + # The module should reference H264VideoToolboxSettings + assert hasattr(mod, "H264VideoToolboxSettings") + assert not hasattr(mod, "HEVCVideoToolboxSettings") diff --git a/tests/encoders/test_helpers.py b/tests/encoders/test_helpers.py index 53150041..00cccebe 100644 --- a/tests/encoders/test_helpers.py +++ b/tests/encoders/test_helpers.py @@ -297,7 +297,9 @@ def test_generate_filters_with_multiple_options(): brightness="0.1", contrast="1.1", saturation="1.2", + gamma="1.5", video_speed=0.5, + sharpen="0.5", ) assert isinstance(result, list) @@ -311,6 +313,8 @@ def test_generate_filters_with_multiple_options(): assert "brightness=0.1" in filter_str assert "saturation=1.2" in filter_str assert "contrast=1.1" in filter_str + assert "gamma=1.5" in filter_str + assert "cas=strength=0.5" in filter_str assert result[2] == "-map" assert result[3] == "[v]" @@ -346,7 +350,10 @@ def test_generate_all(fastflix_instance): assert output_fps == ["-r", "24"] # Verify the mock calls - mock_build_audio.assert_called_once_with(fastflix_instance.current_video.audio_tracks) + mock_build_audio.assert_called_once_with( + fastflix_instance.current_video.audio_tracks, + reverse_video=fastflix_instance.current_video.video_settings.reverse_video, + ) mock_build_subtitle.assert_called_once_with( fastflix_instance.current_video.subtitle_tracks, output_path=fastflix_instance.current_video.video_settings.output_path, @@ -628,3 +635,149 @@ def test_generate_color_details(fastflix_instance): result = generate_color_details(fastflix_instance) assert result == ["-color_primaries", "bt2020", "-color_trc", "smpte2084", "-colorspace", "bt2020nc"] + + +def test_generate_filters_with_vibrance(): + """Test the generate_filters function with vibrance.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + vibrance="0.5", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "vibrance=intensity=0.5" in result[1] + + +def test_generate_filters_with_color_temperature(): + """Test the generate_filters function with color temperature.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + color_temperature="5000", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "colortemperature=temperature=5000" in result[1] + + +def test_generate_filters_with_curves_preset(): + """Test the generate_filters function with curves preset.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + curves_preset="vintage", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "curves=preset=vintage" in result[1] + + +def test_generate_filters_with_colorbalance(): + """Test the generate_filters function with colorbalance.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + colorbalance="colorbalance=rs=0.15:bs=-0.15", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "colorbalance=rs=0.15:bs=-0.15" in result[1] + + +def test_generate_filters_with_unsharp(): + """Test the generate_filters function with unsharp mask.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + unsharp="unsharp=5:5:1.0:5:5:0.5", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "unsharp=5:5:1.0:5:5:0.5" in result[1] + + +def test_generate_filters_with_deflicker(): + """Test the generate_filters function with deflicker.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + deflicker="deflicker=mode=pm:size=5", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "deflicker=mode=pm:size=5" in result[1] + + +def test_generate_filters_with_pad(): + """Test the generate_filters function with pad aspect ratio.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + pad_aspect="16:9", + pad_color="black", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "pad=" in result[1] + assert "color=black" in result[1] + + +def test_generate_filters_with_lut3d(): + """Test the generate_filters function with LUT3D file.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + lut3d_path="/path/to/my.cube", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "lut3d=" in result[1] + assert "my.cube" in result[1] + + +def test_generate_filters_with_nlmeans_opencl(): + """Test the generate_filters function with nlmeans_opencl denoise.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + denoise="nlmeans_opencl=s=1.0:p=3:r=9", + ) + assert isinstance(result, list) + assert result[0] == "-filter_complex" + assert "hwupload" in result[1] + assert "nlmeans_opencl=s=1.0:p=3:r=9" in result[1] + assert "hwdownload" in result[1] + + +def test_generate_filters_filter_chain_order(): + """Test that filters are applied in the correct order.""" + result = generate_filters( + selected_track=0, + source=Path("input.mkv"), + deinterlace=True, + crop={"width": 1920, "height": 1080, "left": 0, "top": 0}, + scale="1920:-8", + denoise="nlmeans=s=1.0:p=3:r=9", + deflicker="deflicker=mode=pm:size=5", + unsharp="unsharp=5:5:1.0:5:5:0.5", + brightness="0.1", + hue="10", + vibrance="0.5", + colorbalance="colorbalance=rs=0.15:bs=-0.15", + color_temperature="5000", + curves_preset="vintage", + sharpen="0.5", + ) + assert isinstance(result, list) + filter_str = result[1] + # Verify ordering: denoise before deflicker before unsharp before eq before hue before vibrance + assert filter_str.index("nlmeans") < filter_str.index("deflicker") + assert filter_str.index("deflicker") < filter_str.index("unsharp") + assert filter_str.index("unsharp") < filter_str.index("eq=eval=frame") + assert filter_str.index("eq=eval=frame") < filter_str.index("hue=h=") + assert filter_str.index("hue=h=") < filter_str.index("vibrance") + assert filter_str.index("vibrance") < filter_str.index("colorbalance") + assert filter_str.index("colorbalance") < filter_str.index("colortemperature") + assert filter_str.index("colortemperature") < filter_str.index("curves=preset") + assert filter_str.index("curves=preset") < filter_str.index("cas=strength") diff --git a/tests/encoders/test_hevc_videotoolbox_command_builder.py b/tests/encoders/test_hevc_videotoolbox_command_builder.py new file mode 100644 index 00000000..548ffd2d --- /dev/null +++ b/tests/encoders/test_hevc_videotoolbox_command_builder.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from fastflix.encoders.hevc_videotoolbox.command_builder import build +from fastflix.encoders.common.helpers import null +from fastflix.models.encode import HEVCVideoToolboxSettings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def _make_fastflix(encoder_settings, video_settings=None): + """Create a FastFlix instance for HEVC VideoToolbox testing.""" + return create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings) + + +# --------------------------------------------------------------------------- +# Profile mapping +# --------------------------------------------------------------------------- +def test_profile_auto_omitted(): + """Profile 'Auto' (index 0) must NOT emit -profile:v — FFmpeg has no profile 0 for HEVC.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(profile=0, q=50)) + result = build(fastflix) + + cmd = result[0].command + assert "-profile:v" not in cmd + + +def test_profile_main(): + """Profile index 1 → -profile:v main.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(profile=1, q=50)) + cmd = build(fastflix)[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "main" + + +def test_profile_main10(): + """Profile index 2 → -profile:v main10.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(profile=2, q=50)) + cmd = build(fastflix)[0].command + + idx = cmd.index("-profile:v") + assert cmd[idx + 1] == "main10" + + +def test_profile_unknown_index_omitted(): + """An index outside the known map (e.g. 99) should NOT emit -profile:v.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(profile=99, q=50)) + cmd = build(fastflix)[0].command + + assert "-profile:v" not in cmd + + +# --------------------------------------------------------------------------- +# Quality (constant-Q) mode +# --------------------------------------------------------------------------- +def test_quality_mode_single_pass(): + """Constant quality mode produces exactly one Command.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=35, bitrate=None)) + result = build(fastflix) + + assert len(result) == 1 + assert result[0].name == "Single pass constant quality" + + +def test_quality_mode_q_value(): + """-q:v must carry the stringified quality value.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=15, bitrate=None)) + cmd = build(fastflix)[0].command + + idx = cmd.index("-q:v") + assert cmd[idx + 1] == "15" + + +def test_quality_mode_no_bitrate_flags(): + """Constant-Q mode must NOT contain -b:v or -pass flags.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50, bitrate=None)) + cmd = build(fastflix)[0].command + + assert "-b:v" not in cmd + assert "-pass" not in cmd + + +# --------------------------------------------------------------------------- +# Bitrate (two-pass) mode +# --------------------------------------------------------------------------- +def test_bitrate_mode_two_passes(): + """Bitrate mode must produce exactly two Commands.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k")) + result = build(fastflix) + + assert len(result) == 2 + assert "First pass" in result[0].name + assert "Second pass" in result[1].name + + +def test_bitrate_mode_pass_flags(): + """Each pass command carries -pass 1 / -pass 2.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k")) + result = build(fastflix) + + cmd1 = result[0].command + assert cmd1[cmd1.index("-pass") + 1] == "1" + + cmd2 = result[1].command + assert cmd2[cmd2.index("-pass") + 1] == "2" + + +def test_bitrate_mode_bitrate_value(): + """-b:v must carry the exact bitrate string.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="12000k")) + result = build(fastflix) + + for cmd_obj in result: + cmd = cmd_obj.command + idx = cmd.index("-b:v") + assert cmd[idx + 1] == "12000k" + + +def test_bitrate_mode_first_pass_null_output(): + """First pass discards output to null device.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k")) + cmd1 = build(fastflix)[0].command + + assert null in cmd1 + assert "-an" in cmd1 # no audio in first pass + + +def test_bitrate_mode_second_pass_no_audio_suppression(): + """Second pass must NOT suppress audio (no -an flag).""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k")) + cmd2 = build(fastflix)[1].command + + assert "-an" not in cmd2 + + +def test_bitrate_mode_passlogfile_shared(): + """Both passes must reference the same -passlogfile path.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k")) + + with mock.patch("fastflix.encoders.hevc_videotoolbox.command_builder.secrets.token_hex", return_value="deadbeef"): + result = build(fastflix) + + logfile_1 = result[0].command[result[0].command.index("-passlogfile") + 1] + logfile_2 = result[1].command[result[1].command.index("-passlogfile") + 1] + assert logfile_1 == logfile_2 + assert "deadbeef" in logfile_1 + + +# --------------------------------------------------------------------------- +# Boolean options +# --------------------------------------------------------------------------- +def test_boolean_options_all_false(): + """Default booleans (False) must emit 'false' strings.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings( + q=50, + allow_sw=False, + require_sw=False, + realtime=False, + frames_before=False, + frames_after=False, + ) + ) + cmd = build(fastflix)[0].command + + for flag in ("-allow_sw", "-require_sw", "-realtime", "-frames_before", "-frames_after"): + idx = cmd.index(flag) + assert cmd[idx + 1] == "false", f"{flag} should be 'false'" + + +def test_boolean_options_all_true(): + """All booleans set to True must emit 'true' strings.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings( + q=50, + allow_sw=True, + require_sw=True, + realtime=True, + frames_before=True, + frames_after=True, + ) + ) + cmd = build(fastflix)[0].command + + for flag in ("-allow_sw", "-require_sw", "-realtime", "-frames_before", "-frames_after"): + idx = cmd.index(flag) + assert cmd[idx + 1] == "true", f"{flag} should be 'true'" + + +def test_boolean_mixed(): + """Verify each boolean flag is independent.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings( + q=50, + allow_sw=True, + require_sw=False, + realtime=True, + frames_before=False, + frames_after=True, + ) + ) + cmd = build(fastflix)[0].command + + assert cmd[cmd.index("-allow_sw") + 1] == "true" + assert cmd[cmd.index("-require_sw") + 1] == "false" + assert cmd[cmd.index("-realtime") + 1] == "true" + assert cmd[cmd.index("-frames_before") + 1] == "false" + assert cmd[cmd.index("-frames_after") + 1] == "true" + + +# --------------------------------------------------------------------------- +# Extra / custom FFmpeg options +# --------------------------------------------------------------------------- +def test_extra_options_quality_mode(): + """Custom extra options are appended in quality mode.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50, extra="-tag:v hvc1")) + cmd = build(fastflix)[0].command + + assert "-tag:v" in cmd + assert "hvc1" in cmd + + +def test_extra_options_bitrate_second_pass(): + """Custom extra options appear in second pass of bitrate mode.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k", extra="-tag:v hvc1")) + result = build(fastflix) + + cmd2 = result[1].command + assert "-tag:v" in cmd2 + assert "hvc1" in cmd2 + + +def test_extra_both_passes(): + """Extra options appear in BOTH passes when extra_both_passes is True.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k", extra="-tag:v hvc1", extra_both_passes=True) + ) + result = build(fastflix) + + cmd1 = result[0].command + cmd2 = result[1].command + assert "-tag:v" in cmd1 + assert "-tag:v" in cmd2 + + +def test_no_extra_in_first_pass_by_default(): + """Without extra_both_passes, first pass must NOT have extra options.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(q=None, bitrate="6000k", extra="-tag:v hvc1", extra_both_passes=False) + ) + result = build(fastflix) + + cmd1 = result[0].command + assert "-tag:v" not in cmd1 + + +def test_empty_extra_no_crash(): + """Empty extra string must not inject empty args.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50, extra="")) + cmd = build(fastflix)[0].command + + assert "" not in cmd # no empty-string elements + + +# --------------------------------------------------------------------------- +# Pixel format +# --------------------------------------------------------------------------- +def test_default_pix_fmt_hevc(): + """HEVC VideoToolbox defaults to p010le (10-bit for HDR).""" + settings = HEVCVideoToolboxSettings() + assert settings.pix_fmt == "p010le" + + +def test_pix_fmt_in_command(): + """The pix_fmt must appear in the generated command via generate_all.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50, pix_fmt="yuv420p")) + cmd = build(fastflix)[0].command + + assert "-pix_fmt" in cmd + idx = cmd.index("-pix_fmt") + assert cmd[idx + 1] == "yuv420p" + + +# --------------------------------------------------------------------------- +# Encoder identification +# --------------------------------------------------------------------------- +def test_encoder_name_in_command(): + """Command must contain hevc_videotoolbox as the encoder.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50)) + cmd = build(fastflix)[0].command + + assert "hevc_videotoolbox" in cmd + + +def test_command_exe_is_ffmpeg(): + """All commands must declare exe='ffmpeg'.""" + fastflix = _make_fastflix(encoder_settings=HEVCVideoToolboxSettings(q=50)) + for cmd_obj in build(fastflix): + assert cmd_obj.exe == "ffmpeg" + + +# --------------------------------------------------------------------------- +# Start/end time +# --------------------------------------------------------------------------- +def test_start_end_time(): + """Start and end times must be stringified in the command.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(q=50), + video_settings=VideoSettings( + start_time=5.5, + end_time=30.0, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + cmd = build(fastflix)[0].command + + assert "-ss" in cmd + assert "5.5" in cmd + assert "-to" in cmd + assert "30.0" in cmd + + +# --------------------------------------------------------------------------- +# Color details +# --------------------------------------------------------------------------- +def test_color_details_preserved(): + """Color primaries/transfer/space should be in the command when HDR is not removed.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(q=50), + video_settings=VideoSettings( + remove_hdr=False, + color_primaries="bt2020", + color_transfer="smpte2084", + color_space="bt2020nc", + maxrate=None, + bufsize=None, + ), + ) + cmd = build(fastflix)[0].command + + assert "-color_primaries" in cmd + assert "bt2020" in cmd + assert "-color_trc" in cmd + assert "smpte2084" in cmd + assert "-colorspace" in cmd + assert "bt2020nc" in cmd + + +# --------------------------------------------------------------------------- +# All elements are strings (regression guard) +# --------------------------------------------------------------------------- +def test_all_elements_are_strings_quality(): + """Every element in the quality-mode command must be a string.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(profile=2, q=50, allow_sw=True), + video_settings=VideoSettings( + start_time=5.0, + end_time=30.0, + remove_hdr=False, + maxrate=8000, + bufsize=16000, + video_title="Test", + ), + ) + cmd = build(fastflix)[0].command + + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + +def test_all_elements_are_strings_bitrate(): + """Every element in both bitrate-mode commands must be a string.""" + fastflix = _make_fastflix( + encoder_settings=HEVCVideoToolboxSettings(profile=1, q=None, bitrate="6000k", allow_sw=True), + video_settings=VideoSettings( + start_time=1.0, + end_time=10.0, + remove_hdr=False, + maxrate=None, + bufsize=None, + ), + ) + for cmd_obj in build(fastflix): + for i, element in enumerate(cmd_obj.command): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + +# --------------------------------------------------------------------------- +# Model defaults +# --------------------------------------------------------------------------- +def test_settings_defaults(): + """Verify sensible defaults on the model.""" + s = HEVCVideoToolboxSettings() + assert s.profile == 0 + assert s.q == 50 + assert s.bitrate is None + assert s.pix_fmt == "p010le" + assert s.allow_sw is False + assert s.require_sw is False + assert s.realtime is False + assert s.frames_before is False + assert s.frames_after is False diff --git a/tests/encoders/test_nvencc_hevc_command_builder.py b/tests/encoders/test_nvencc_hevc_command_builder.py index 514d532d..0fe2def7 100644 --- a/tests/encoders/test_nvencc_hevc_command_builder.py +++ b/tests/encoders/test_nvencc_hevc_command_builder.py @@ -145,7 +145,7 @@ def test_nvencc_hevc_with_crop_scale(): video_settings=VideoSettings( crop=Crop(left=10, top=20, right=10, bottom=20, width=1900, height=1040), resolution_method="custom", - resolution_custom="1280x720", + resolution_custom="1280:720", remove_hdr=False, maxrate=None, bufsize=None, diff --git a/tests/encoders/test_qsvencc_av1_command_builder.py b/tests/encoders/test_qsvencc_av1_command_builder.py new file mode 100644 index 00000000..b3464551 --- /dev/null +++ b/tests/encoders/test_qsvencc_av1_command_builder.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +from pathlib import Path +from unittest import mock + +from box import Box + +from fastflix.encoders.qsvencc_av1.command_builder import build +from fastflix.models.encode import QSVEncCAV1Settings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def _make_fastflix(encoder_settings, video_settings=None, stream_extras=None): + """Create a FastFlix instance with QSVEncC AV1-compatible video stream data.""" + fastflix = create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings) + stream_data = { + "index": 0, + "id": "0x1", + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "color_space": "bt2020nc", + "color_transfer": "smpte2084", + "color_primaries": "bt2020", + "chroma_location": "left", + "bit_depth": 10, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "width": 1920, + "height": 1080, + } + if stream_extras: + stream_data.update(stream_extras) + fastflix.current_video.streams = Box({"video": [Box(stream_data)], "audio": [], "subtitle": []}) + fastflix.config.qsvencc = Path("QSVEncC64") + return fastflix + + +def test_qsvencc_av1_basic_cqp(): + """Test QSVEncC AV1 build with CQP mode produces all-string command list.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate=None, + cqp=22, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--cqp" in cmd + assert "22" in cmd + assert "-c" in cmd + assert "av1" in cmd + assert "QSVEncC64" in cmd + + +def test_qsvencc_av1_tune_hq(): + """Test that --tune hq is added to the command when tune is set.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate=None, + cqp=22, + tune="hq", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "hq" + + +def test_qsvencc_av1_tune_ll(): + """Test that --tune ll (low latency) is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate=None, + cqp=22, + tune="ll", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "ll" + + +def test_qsvencc_av1_tune_lossless(): + """Test that --tune lossless is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate=None, + cqp=22, + tune="lossless", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "lossless" + + +def test_qsvencc_av1_tune_none(): + """Test that --tune is NOT in the command when tune is None.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate=None, + cqp=22, + tune=None, + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" not in cmd + + +def test_qsvencc_av1_tune_default_is_none(): + """Test that tune defaults to None when not specified.""" + settings = QSVEncCAV1Settings(bitrate=None, cqp=22) + assert settings.tune is None + + +def test_qsvencc_av1_with_bitrate(): + """Test QSVEncC AV1 build with VBR bitrate mode.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate="6000k", + cqp=None, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + assert "--vbr" in cmd + assert "6000" in cmd + assert "--cqp" not in cmd + + +def test_qsvencc_av1_all_elements_are_strings(): + """Comprehensive test: build with many options and verify all elements are strings.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCAV1Settings( + bitrate="5000k", + cqp=None, + preset="best", + level="5.1", + lookahead="32", + b_frames="3", + ref="4", + tune="ll", + ), + video_settings=VideoSettings( + start_time=5.5, + end_time=60.0, + source_fps="24", + remove_hdr=False, + maxrate=8000, + bufsize=16000, + video_title="Test Title", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_av1.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--tune" in cmd + assert "--quality" in cmd diff --git a/tests/encoders/test_qsvencc_avc_command_builder.py b/tests/encoders/test_qsvencc_avc_command_builder.py new file mode 100644 index 00000000..74570ff1 --- /dev/null +++ b/tests/encoders/test_qsvencc_avc_command_builder.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +from pathlib import Path +from unittest import mock + +from box import Box + +from fastflix.encoders.qsvencc_avc.command_builder import build +from fastflix.models.encode import QSVEncCH264Settings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def _make_fastflix(encoder_settings, video_settings=None, stream_extras=None): + """Create a FastFlix instance with QSVEncC AVC-compatible video stream data.""" + fastflix = create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings) + stream_data = { + "index": 0, + "id": "0x1", + "codec_name": "h264", + "codec_type": "video", + "pix_fmt": "yuv420p", + "color_space": "bt709", + "color_transfer": "bt709", + "color_primaries": "bt709", + "chroma_location": "left", + "bit_depth": 8, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "width": 1920, + "height": 1080, + } + if stream_extras: + stream_data.update(stream_extras) + fastflix.current_video.streams = Box({"video": [Box(stream_data)], "audio": [], "subtitle": []}) + fastflix.config.qsvencc = Path("QSVEncC64") + return fastflix + + +def test_qsvencc_avc_basic_cqp(): + """Test QSVEncC AVC build with CQP mode produces all-string command list.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--cqp" in cmd + assert "22" in cmd + assert "-c" in cmd + assert "h264" in cmd + assert "QSVEncC64" in cmd + + +def test_qsvencc_avc_tune_hq(): + """Test that --tune hq is added to the command when tune is set.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + tune="hq", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "hq" + + +def test_qsvencc_avc_tune_ll(): + """Test that --tune ll (low latency) is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + tune="ll", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "ll" + + +def test_qsvencc_avc_tune_lossless(): + """Test that --tune lossless is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + tune="lossless", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "lossless" + + +def test_qsvencc_avc_tune_none(): + """Test that --tune is NOT in the command when tune is None.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + tune=None, + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" not in cmd + + +def test_qsvencc_avc_tune_default_is_none(): + """Test that tune defaults to None when not specified.""" + settings = QSVEncCH264Settings(bitrate=None, cqp=22) + assert settings.tune is None + + +def test_qsvencc_avc_with_profile(): + """Test QSVEncC AVC build includes --profile flag.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate=None, + cqp=22, + profile="high", + tune="hq", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--profile" in cmd + profile_idx = cmd.index("--profile") + assert cmd[profile_idx + 1] == "high" + assert "--tune" in cmd + + +def test_qsvencc_avc_with_bitrate(): + """Test QSVEncC AVC build with VBR bitrate mode.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate="6000k", + cqp=None, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + assert "--vbr" in cmd + assert "6000" in cmd + assert "--cqp" not in cmd + + +def test_qsvencc_avc_all_elements_are_strings(): + """Comprehensive test: build with many options and verify all elements are strings.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCH264Settings( + bitrate="5000k", + cqp=None, + preset="best", + profile="high", + level="5.1", + lookahead="32", + b_frames="3", + ref="4", + tune="ull", + ), + video_settings=VideoSettings( + start_time=5.5, + end_time=60.0, + source_fps="24", + remove_hdr=False, + maxrate=8000, + bufsize=16000, + video_title="Test Title", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_avc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--tune" in cmd + assert "--quality" in cmd + assert "--profile" in cmd diff --git a/tests/encoders/test_qsvencc_hevc_command_builder.py b/tests/encoders/test_qsvencc_hevc_command_builder.py new file mode 100644 index 00000000..4cc8dda3 --- /dev/null +++ b/tests/encoders/test_qsvencc_hevc_command_builder.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +from pathlib import Path +from unittest import mock + +from box import Box + +from fastflix.encoders.qsvencc_hevc.command_builder import build +from fastflix.models.encode import QSVEncCSettings +from fastflix.models.video import VideoSettings + +from tests.conftest import create_fastflix_instance + + +def _make_fastflix(encoder_settings, video_settings=None, stream_extras=None): + """Create a FastFlix instance with QSVEncC-compatible video stream data.""" + fastflix = create_fastflix_instance(encoder_settings=encoder_settings, video_settings=video_settings) + stream_data = { + "index": 0, + "id": "0x1", + "codec_name": "hevc", + "codec_type": "video", + "pix_fmt": "yuv420p10le", + "color_space": "bt2020nc", + "color_transfer": "smpte2084", + "color_primaries": "bt2020", + "chroma_location": "left", + "bit_depth": 10, + "r_frame_rate": "24000/1001", + "avg_frame_rate": "24000/1001", + "width": 1920, + "height": 1080, + } + if stream_extras: + stream_data.update(stream_extras) + fastflix.current_video.streams = Box({"video": [Box(stream_data)], "audio": [], "subtitle": []}) + fastflix.config.qsvencc = Path("QSVEncC64") + return fastflix + + +def test_qsvencc_hevc_basic_cqp(): + """Test QSVEncC HEVC build with CQP mode produces all-string command list.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + assert len(result) == 1 + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--cqp" in cmd + assert "22" in cmd + assert "--quality" in cmd + assert "best" in cmd + assert "-c" in cmd + assert "hevc" in cmd + assert "QSVEncC64" in cmd + + +def test_qsvencc_hevc_tune_hq(): + """Test that --tune hq is added to the command when tune is set.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + tune="hq", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "hq" + + +def test_qsvencc_hevc_tune_ll(): + """Test that --tune ll (low latency) is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + tune="ll", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "ll" + + +def test_qsvencc_hevc_tune_ull(): + """Test that --tune ull (ultra low latency) is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + tune="ull", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "ull" + + +def test_qsvencc_hevc_tune_lossless(): + """Test that --tune lossless is added to the command.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + tune="lossless", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" in cmd + tune_idx = cmd.index("--tune") + assert cmd[tune_idx + 1] == "lossless" + + +def test_qsvencc_hevc_tune_none(): + """Test that --tune is NOT in the command when tune is None.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate=None, + cqp=22, + tune=None, + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert "--tune" not in cmd + + +def test_qsvencc_hevc_tune_default_is_none(): + """Test that tune defaults to None when not specified.""" + settings = QSVEncCSettings(bitrate=None, cqp=22) + assert settings.tune is None + + +def test_qsvencc_hevc_with_bitrate(): + """Test QSVEncC HEVC build with VBR bitrate mode.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate="6000k", + cqp=None, + preset="best", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + assert "--vbr" in cmd + assert "6000" in cmd + assert "--cqp" not in cmd + + +def test_qsvencc_hevc_all_elements_are_strings(): + """Comprehensive test: build with many options and verify all elements are strings.""" + fastflix = _make_fastflix( + encoder_settings=QSVEncCSettings( + bitrate="5000k", + cqp=None, + preset="best", + level="5.1", + lookahead="32", + b_frames="3", + ref="4", + tune="hq", + ), + video_settings=VideoSettings( + start_time=5.5, + end_time=60.0, + source_fps="24", + remove_hdr=False, + maxrate=8000, + bufsize=16000, + video_title="Test Title", + ), + ) + + with mock.patch("fastflix.encoders.qsvencc_hevc.command_builder.rigaya_auto_options", return_value=[]): + result = build(fastflix) + + cmd = result[0].command + assert isinstance(cmd, list) + for i, element in enumerate(cmd): + assert isinstance(element, str), f"Element at index {i} is {type(element).__name__}: {element!r}" + + assert "--tune" in cmd + assert "--quality" in cmd diff --git a/tests/encoders/test_subtitles.py b/tests/encoders/test_subtitles.py index a44d980d..ee5abe78 100644 --- a/tests/encoders/test_subtitles.py +++ b/tests/encoders/test_subtitles.py @@ -248,3 +248,91 @@ def test_build_subtitle_external_defaults_no_break(): # Default file_index is 0, so -map should be 0:3 result, _, _ = build_subtitle([track]) assert "0:3" in result + + +def test_disposition_clear_default(): + """When profile clears default, no track should have default disposition.""" + tracks = [ + SubtitleTrack(index=0, outdex=1, language="eng", enabled=True, dispositions={"default": True, "forced": False}), + SubtitleTrack( + index=1, outdex=2, language="jpn", enabled=True, dispositions={"default": False, "forced": False} + ), + ] + # Simulate "clear" mode — clear default from all tracks + for track in tracks: + track.dispositions["default"] = False + + result, _, _ = build_subtitle(tracks) + # No default disposition means infer_no_subs should be added + assert "-default_mode" in result + assert "infer_no_subs" in result + # Verify disposition:1 is "0" (no flags) + disp_idx = result.index("-disposition:1") + assert result[disp_idx + 1] == "0" + + +def test_disposition_set_first_default(): + """When profile sets default on first, only first enabled track gets default.""" + tracks = [ + SubtitleTrack( + index=0, outdex=1, language="eng", enabled=True, dispositions={"default": False, "forced": False} + ), + SubtitleTrack( + index=1, outdex=2, language="jpn", enabled=True, dispositions={"default": False, "forced": False} + ), + ] + # Simulate "first" mode — set default on first enabled, clear rest + first_set = False + for track in tracks: + track.dispositions["default"] = not first_set + first_set = True + + result, _, _ = build_subtitle(tracks) + # First track should have default, second should not + assert "-default_mode" not in result # default is set, so no infer_no_subs + disp1_idx = result.index("-disposition:1") + assert "default" in result[disp1_idx + 1] + disp2_idx = result.index("-disposition:2") + assert result[disp2_idx + 1] == "0" + + +def test_disposition_clear_forced(): + """When profile clears forced, no track should have forced disposition.""" + tracks = [ + SubtitleTrack(index=0, outdex=1, language="eng", enabled=True, dispositions={"default": False, "forced": True}), + SubtitleTrack( + index=1, outdex=2, language="jpn", enabled=True, dispositions={"default": False, "forced": False} + ), + ] + # Simulate "clear" mode + for track in tracks: + track.dispositions["forced"] = False + + result, _, _ = build_subtitle(tracks) + assert "-default_mode" in result + # First track should have disposition "0" + disp_idx = result.index("-disposition:1") + assert result[disp_idx + 1] == "0" + + +def test_disposition_set_first_forced(): + """When profile sets forced on first, only first enabled track gets forced.""" + tracks = [ + SubtitleTrack( + index=0, outdex=1, language="eng", enabled=True, dispositions={"default": False, "forced": False} + ), + SubtitleTrack( + index=1, outdex=2, language="jpn", enabled=True, dispositions={"default": False, "forced": False} + ), + ] + # Simulate "first" mode + first_set = False + for track in tracks: + track.dispositions["forced"] = not first_set + first_set = True + + result, _, _ = build_subtitle(tracks) + disp1_idx = result.index("-disposition:1") + assert "forced" in result[disp1_idx + 1] + disp2_idx = result.index("-disposition:2") + assert result[disp2_idx + 1] == "0" diff --git a/tests/encoders/test_svt_av1_command_builder.py b/tests/encoders/test_svt_av1_command_builder.py index 27154ce4..29edf794 100644 --- a/tests/encoders/test_svt_av1_command_builder.py +++ b/tests/encoders/test_svt_av1_command_builder.py @@ -101,7 +101,7 @@ def test_svt_av1_two_pass_qp(): fastflix = create_fastflix_instance( encoder_settings=SVTAV1Settings( qp=24, - qp_mode="crf", + qp_mode="qp", speed="7", tile_columns="0", tile_rows="0", diff --git a/tests/general.py b/tests/general.py index 2f834e6f..ca907077 100644 --- a/tests/general.py +++ b/tests/general.py @@ -12,6 +12,7 @@ "channels": 6, "codec_long_name": "TrueHD", "codec_name": "truehd", + "profile": "TrueHD+Atmos", "codec_tag": "0x0000", "codec_tag_string": "[0][0][0][0]", "codec_type": "audio", @@ -200,5 +201,91 @@ }, "time_base": "1/1000", }, + { + "avg_frame_rate": "0/0", + "bits_per_raw_sample": "24", + "bits_per_sample": 0, + "channel_layout": "5.1(side)", + "channels": 6, + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "codec_name": "dts", + "profile": "DTS-HD MA", + "codec_tag": "0x0000", + "codec_tag_string": "[0][0][0][0]", + "codec_type": "audio", + "disposition": { + "attached_pic": 0, + "captions": 0, + "clean_effects": 0, + "comment": 0, + "default": 0, + "dependent": 0, + "descriptions": 0, + "dub": 0, + "forced": 0, + "hearing_impaired": 0, + "karaoke": 0, + "lyrics": 0, + "metadata": 0, + "original": 0, + "still_image": 0, + "timed_thumbnails": 0, + "visual_impaired": 0, + }, + "index": 5, + "r_frame_rate": "0/0", + "sample_fmt": "s32p", + "sample_rate": "48000", + "start_pts": 0, + "start_time": "0.000000", + "tags": { + "language": "eng", + "title": "DTS-HD MA 5.1", + }, + "time_base": "1/1000", + }, + { + "avg_frame_rate": "0/0", + "bit_rate": "1536000", + "bits_per_sample": 0, + "channel_layout": "5.1(side)", + "channels": 6, + "codec_long_name": "DCA (DTS Coherent Acoustics)", + "codec_name": "dts", + "profile": "DTS", + "codec_tag": "0x0000", + "codec_tag_string": "[0][0][0][0]", + "codec_type": "audio", + "disposition": { + "attached_pic": 0, + "captions": 0, + "clean_effects": 0, + "comment": 0, + "default": 0, + "dependent": 0, + "descriptions": 0, + "dub": 0, + "forced": 0, + "hearing_impaired": 0, + "karaoke": 0, + "lyrics": 0, + "metadata": 0, + "original": 0, + "still_image": 0, + "timed_thumbnails": 0, + "visual_impaired": 0, + }, + "index": 6, + "r_frame_rate": "0/0", + "sample_fmt": "fltp", + "sample_rate": "48000", + "start_pts": 0, + "start_time": "0.000000", + "tags": { + "language": "eng", + "title": "DTS 5.1", + }, + "time_base": "1/1000", + }, ] ) diff --git a/tests/media/chapters_timecode.mp4 b/tests/media/chapters_timecode.mp4 new file mode 100644 index 00000000..c955bd49 Binary files /dev/null and b/tests/media/chapters_timecode.mp4 differ diff --git a/tests/media/font_attachment_data.mkv b/tests/media/font_attachment_data.mkv new file mode 100644 index 00000000..ad332b5e Binary files /dev/null and b/tests/media/font_attachment_data.mkv differ diff --git a/tests/media/multi_stream.mp4 b/tests/media/multi_stream.mp4 new file mode 100644 index 00000000..093f3a85 Binary files /dev/null and b/tests/media/multi_stream.mp4 differ diff --git a/tests/media/test_color_shift.cube b/tests/media/test_color_shift.cube new file mode 100644 index 00000000..cb9ce0b7 --- /dev/null +++ b/tests/media/test_color_shift.cube @@ -0,0 +1,22 @@ +# Test LUT - swaps red and blue channels for obvious visual difference +# Size 2: entries ordered R-fastest, then G, then B +# Input (R,G,B) -> Output (B,G,R) +TITLE "Test Red-Blue Swap" +LUT_3D_SIZE 2 + +# (0,0,0) -> (0,0,0) +0.0 0.0 0.0 +# (1,0,0) -> (0,0,1) +0.0 0.0 1.0 +# (0,1,0) -> (0,1,0) +0.0 1.0 0.0 +# (1,1,0) -> (0,1,1) +0.0 1.0 1.0 +# (0,0,1) -> (1,0,0) +1.0 0.0 0.0 +# (1,0,1) -> (1,0,1) +1.0 0.0 1.0 +# (0,1,1) -> (1,1,0) +1.0 1.0 0.0 +# (1,1,1) -> (1,1,1) +1.0 1.0 1.0 diff --git a/tests/test_advanced_panel_layout.py b/tests/test_advanced_panel_layout.py new file mode 100644 index 00000000..94bac0a6 --- /dev/null +++ b/tests/test_advanced_panel_layout.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +"""Tests for the Advanced panel QGroupBox layout. + +Verifies that the panel renders correctly at realistic sizes, +the scroll area works, and groups don't compress. +""" + +import pytest +from unittest import mock +from PySide6 import QtWidgets + + +@pytest.fixture(scope="session") +def qapp(): + """Create or reuse a QApplication instance for the test session.""" + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + return app + + +@pytest.fixture +def mock_app(): + """Create a mock FastFlixApp with the minimum config needed by AdvancedPanel.""" + app = mock.MagicMock() + app.fastflix.config.theme = "onyx" + app.fastflix.config.suppress_video_speed_warning = True + app.fastflix.config.suppress_reverse_video_warning = True + app.fastflix.current_video = None + return app + + +@pytest.fixture +def mock_parent(qapp): + """Create a real QWidget parent with a mocked .main attribute.""" + parent = QtWidgets.QWidget() + parent.main = mock.MagicMock() + parent.main.page_update = mock.MagicMock() + parent.main.remove_hdr = False + return parent + + +@pytest.fixture +def advanced_panel(mock_parent, mock_app): + """Create a real AdvancedPanel instance with mocked dependencies.""" + from fastflix.widgets.panels.advanced_panel import AdvancedPanel + + panel = AdvancedPanel(mock_parent, mock_app) + return panel + + +# --- Structure tests --- + + +def test_panel_has_scroll_area(advanced_panel): + """The panel must contain a QScrollArea as its main child.""" + scroll_areas = advanced_panel.findChildren(QtWidgets.QScrollArea) + assert len(scroll_areas) == 1, "Panel should have exactly one QScrollArea" + + +def test_panel_has_six_groups(advanced_panel): + """The panel must contain exactly 6 QGroupBox sections.""" + groups = advanced_panel.findChildren(QtWidgets.QGroupBox) + assert len(groups) == 6, f"Expected 6 QGroupBox, found {len(groups)}" + titles = sorted(g.title() for g in groups) + assert "Color" in titles + assert "Color & Appearance" in titles + assert "Frame Rate" in titles + assert "Output" in titles + assert "Video Details" in titles + assert "Video Processing" in titles + + +def test_all_key_widgets_exist(advanced_panel): + """All key widget attributes must be present.""" + widgets = [ + "incoming_fps_widget", + "outgoing_fps_widget", + "incoming_same_as_source", + "outgoing_same_as_source", + "vsync_widget", + "video_speed_widget", + "reverse_video_widget", + "tone_map_widget", + "brightness_widget", + "contrast_widget", + "saturation_widget", + "gamma_widget", + "hue_widget", + "denoise_type_widget", + "denoise_strength_widget", + "deblock_widget", + "deblock_size_widget", + "color_primaries_widget", + "color_transfer_widget", + "color_space_widget", + "maxrate_widget", + "bufsize_widget", + "video_title", + "video_track_title", + "sharpen_widget", + "gop_length_widget", + "faststart_widget", + "vibrance_widget", + "color_temperature_widget", + "curves_preset_widget", + "colorbalance_widget", + "unsharp_widget", + "deflicker_widget", + "pad_aspect_widget", + "pad_color_widget", + "lut3d_path_widget", + "lut3d_browse_button", + "lut3d_clear_button", + ] + for name in widgets: + assert hasattr(advanced_panel, name), f"Missing widget: {name}" + assert getattr(advanced_panel, name) is not None, f"Widget is None: {name}" + + +# --- Grid column stretch tests --- + + +def test_groups_have_column_stretch(advanced_panel): + """Each group's grid layout must have column stretch set on all columns.""" + groups = advanced_panel.findChildren(QtWidgets.QGroupBox) + for group in groups: + gl = group.layout() + assert isinstance(gl, QtWidgets.QGridLayout), f"{group.title()} should use QGridLayout" + col_count = gl.columnCount() + for col in range(col_count): + stretch = gl.columnStretch(col) + assert stretch > 0, f"{group.title()} column {col} has no stretch (stretch={stretch})" + + +# --- Scroll behavior tests --- + + +REALISTIC_PANEL_WIDTH = 1500 +REALISTIC_PANEL_HEIGHT = 400 # typical tab area height in FastFlix + + +def test_container_has_minimum_height(advanced_panel): + """The scroll area's container widget must have a minimum height > the typical viewport.""" + scroll = advanced_panel.findChild(QtWidgets.QScrollArea) + container = scroll.widget() + assert container.minimumHeight() >= 500, ( + f"Container minimumHeight ({container.minimumHeight()}) must be >= 500 " + f"to prevent compression at typical viewport size ({REALISTIC_PANEL_HEIGHT}px)" + ) + + +def test_scroll_area_scrolls_at_small_size(advanced_panel): + """When the panel is small, the vertical scrollbar must be available.""" + advanced_panel.resize(REALISTIC_PANEL_WIDTH, REALISTIC_PANEL_HEIGHT) + advanced_panel.show() + # Force layout recalculation + QtWidgets.QApplication.processEvents() + + scroll = advanced_panel.findChild(QtWidgets.QScrollArea) + vbar = scroll.verticalScrollBar() + + # The scrollbar should have a range > 0 (meaning content overflows viewport) + assert vbar.maximum() > 0, ( + f"Scroll area should be scrollable at {REALISTIC_PANEL_WIDTH}x{REALISTIC_PANEL_HEIGHT}, " + f"but scrollbar max is {vbar.maximum()}" + ) + advanced_panel.hide() + + +def test_groups_not_compressed_at_small_size(advanced_panel): + """At small viewport size, groups must maintain usable height (not crushed).""" + advanced_panel.resize(REALISTIC_PANEL_WIDTH, REALISTIC_PANEL_HEIGHT) + advanced_panel.show() + QtWidgets.QApplication.processEvents() + + groups = advanced_panel.findChildren(QtWidgets.QGroupBox) + min_usable_heights = { + "Video Details": 50, + "Frame Rate": 70, + "Video Processing": 160, + "Color": 50, + "Output": 50, + } + for group in groups: + expected_min = min_usable_heights.get(group.title(), 50) + actual_height = group.height() + assert actual_height >= expected_min, ( + f"{group.title()} height is {actual_height}px, expected at least {expected_min}px for usability" + ) + advanced_panel.hide() + + +def test_page_update_does_not_crash(mock_parent, mock_app, qapp): + """page_update must not crash during or after init (widgets may not exist yet during init).""" + from fastflix.widgets.panels.advanced_panel import AdvancedPanel + + # This would raise AttributeError if page_update references widgets before they're created + panel = AdvancedPanel(mock_parent, mock_app) + panel.page_update() + panel.page_update(build_thumbnail=True) + + +def test_faststart_visible_for_mp4(advanced_panel): + """Fast Start toggle must not be hidden when output type is .mp4.""" + combo = QtWidgets.QComboBox() + combo.addItems([".mp4", ".mkv"]) + combo.setCurrentText(".mp4") + advanced_panel.main.widgets.output_type_combo = combo + + advanced_panel.update_faststart_visibility() + assert not advanced_panel.faststart_widget.isHidden(), "Fast Start should not be hidden for .mp4" + + +def test_faststart_visible_for_mov(advanced_panel): + """Fast Start toggle must not be hidden when output type is .mov.""" + combo = QtWidgets.QComboBox() + combo.addItems([".mov", ".mkv"]) + combo.setCurrentText(".mov") + advanced_panel.main.widgets.output_type_combo = combo + + advanced_panel.update_faststart_visibility() + assert not advanced_panel.faststart_widget.isHidden(), "Fast Start should not be hidden for .mov" + + +def test_faststart_hidden_for_mkv(advanced_panel): + """Fast Start toggle must be hidden when output type is .mkv.""" + combo = QtWidgets.QComboBox() + combo.addItems([".mkv", ".mp4"]) + combo.setCurrentText(".mkv") + advanced_panel.main.widgets.output_type_combo = combo + + advanced_panel.update_faststart_visibility() + assert advanced_panel.faststart_widget.isHidden(), "Fast Start should be hidden for .mkv" + + +def test_faststart_hidden_for_webm(advanced_panel): + """Fast Start toggle must be hidden when output type is .webm.""" + combo = QtWidgets.QComboBox() + combo.addItems([".webm"]) + combo.setCurrentText(".webm") + advanced_panel.main.widgets.output_type_combo = combo + + advanced_panel.update_faststart_visibility() + assert advanced_panel.faststart_widget.isHidden(), "Fast Start should be hidden for .webm" diff --git a/tests/test_audio.py b/tests/test_audio.py index b020cb97..899ab3ab 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -41,254 +41,191 @@ def test_audio_filters(): result = apply_audio_filters(audio_filters=test_filters, original_tracks=test_audio_tracks) - expected_result = [ - ( - Box( - { - "avg_frame_rate": "0/0", - "bits_per_raw_sample": "24", - "bits_per_sample": 0, - "channel_layout": "5.1(side)", - "channels": 6, - "codec_long_name": "TrueHD", - "codec_name": "truehd", - "codec_tag": "0x0000", - "codec_tag_string": "[0][0][0][0]", - "codec_type": "audio", - "disposition": { - "attached_pic": 0, - "captions": 0, - "clean_effects": 0, - "comment": 0, - "default": 0, - "dependent": 0, - "descriptions": 0, - "dub": 0, - "forced": 0, - "hearing_impaired": 0, - "karaoke": 0, - "lyrics": 0, - "metadata": 0, - "original": 0, - "still_image": 0, - "timed_thumbnails": 0, - "visual_impaired": 0, - }, - "index": 1, - "r_frame_rate": "0/0", - "sample_fmt": "s32", - "sample_rate": "48000", - "start_pts": 0, - "start_time": "0.000000", - "tags": { - "BPS-eng": "1921846", - "DURATION-eng": "00:23:38.083333333", - "NUMBER_OF_BYTES-eng": "340667312", - "NUMBER_OF_FRAMES-eng": "1701700", - "SOURCE_ID-eng": "001100", - "_STATISTICS_TAGS-eng": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES SOURCE_ID", - "_STATISTICS_WRITING_DATE_UTC-eng": "2021-04-21 20:00:45", - "language": "eng", - "title": "Surround 5.1", - }, - "time_base": "1/1000", - } - ), - AudioMatch( - match_type=MatchType.FIRST, - match_item=MatchItem.TITLE, - match_input="Surround 5", - conversion=None, - bitrate="32k", - downmix="No Downmix", - ), + # Results are sorted by index; verify track indices and their matched filters + result_indices = [track.index for track, _ in result] + + # FIRST title "Surround 5" -> index 1 (truehd) + # LAST ALL -> index 6 (last track, dts) + # ALL language eng -> indices 1, 2, 5, 6 + # Sorted by index (stable sort preserves insertion order for same index) + assert result_indices == [1, 1, 2, 5, 6, 6] + + # First result: truehd track matched by TITLE + assert result[0][0].codec_name == "truehd" + assert result[0][1].match_item == MatchItem.TITLE + + # Second result: truehd track matched by LANGUAGE eng + assert result[1][0].codec_name == "truehd" + assert result[1][1].match_item == MatchItem.LANGUAGE + + # Third result: ac3 track matched by LANGUAGE eng + assert result[2][0].codec_name == "ac3" + assert result[2][0].index == 2 + assert result[2][1].match_item == MatchItem.LANGUAGE + + # Fourth result: dts DTS-HD MA track matched by LANGUAGE eng + assert result[3][0].codec_name == "dts" + assert result[3][0].index == 5 + assert result[3][1].match_item == MatchItem.LANGUAGE + + # Fifth result: dts track matched by LAST ALL (inserted before LANGUAGE for same index) + assert result[4][0].index == 6 + assert result[4][1].match_item == MatchItem.ALL + + # Sixth result: dts track matched by LANGUAGE eng + assert result[5][0].codec_name == "dts" + assert result[5][0].index == 6 + assert result[5][1].match_item == MatchItem.LANGUAGE + + +def test_audio_filters_codec_match(): + """Test matching audio tracks by codec name.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC, + match_input="truehd", ), - ( - Box( - { - "avg_frame_rate": "0/0", - "bits_per_raw_sample": "24", - "bits_per_sample": 0, - "channel_layout": "5.1(side)", - "channels": 6, - "codec_long_name": "TrueHD", - "codec_name": "truehd", - "codec_tag": "0x0000", - "codec_tag_string": "[0][0][0][0]", - "codec_type": "audio", - "disposition": { - "attached_pic": 0, - "captions": 0, - "clean_effects": 0, - "comment": 0, - "default": 0, - "dependent": 0, - "descriptions": 0, - "dub": 0, - "forced": 0, - "hearing_impaired": 0, - "karaoke": 0, - "lyrics": 0, - "metadata": 0, - "original": 0, - "still_image": 0, - "timed_thumbnails": 0, - "visual_impaired": 0, - }, - "index": 1, - "r_frame_rate": "0/0", - "sample_fmt": "s32", - "sample_rate": "48000", - "start_pts": 0, - "start_time": "0.000000", - "tags": { - "BPS-eng": "1921846", - "DURATION-eng": "00:23:38.083333333", - "NUMBER_OF_BYTES-eng": "340667312", - "NUMBER_OF_FRAMES-eng": "1701700", - "SOURCE_ID-eng": "001100", - "_STATISTICS_TAGS-eng": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES SOURCE_ID", - "_STATISTICS_WRITING_DATE_UTC-eng": "2021-04-21 20:00:45", - "language": "eng", - "title": "Surround 5.1", - }, - "time_base": "1/1000", - } - ), - AudioMatch( - match_type=MatchType.ALL, - match_item=MatchItem.LANGUAGE, - match_input="eng", - conversion=None, - bitrate="32k", - downmix="No Downmix", - ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 2 + assert result[0][0].codec_name == "truehd" + assert result[0][0].index == 1 + assert result[1][0].codec_name == "truehd" + assert result[1][0].index == 3 + + +def test_audio_filters_codec_match_first(): + """Test matching first audio track by codec name.""" + filters = [ + AudioMatch( + match_type=MatchType.FIRST, + match_item=MatchItem.CODEC, + match_input="ac3", ), - ( - Box( - { - "avg_frame_rate": "0/0", - "bit_rate": "448000", - "bits_per_sample": 0, - "channel_layout": "5.1(side)", - "channels": 6, - "codec_long_name": "ATSC A/52A (AC-3)", - "codec_name": "ac3", - "codec_tag": "0x0000", - "codec_tag_string": "[0][0][0][0]", - "codec_type": "audio", - "disposition": { - "attached_pic": 0, - "captions": 0, - "clean_effects": 0, - "comment": 0, - "default": 0, - "dependent": 0, - "descriptions": 0, - "dub": 0, - "forced": 0, - "hearing_impaired": 0, - "karaoke": 0, - "lyrics": 0, - "metadata": 0, - "original": 0, - "still_image": 0, - "timed_thumbnails": 0, - "visual_impaired": 0, - }, - "index": 2, - "r_frame_rate": "0/0", - "sample_fmt": "fltp", - "sample_rate": "48000", - "start_pts": 0, - "start_time": "0.000000", - "tags": { - "BPS-eng": "448000", - "DURATION-eng": "00:23:38.112000000", - "NUMBER_OF_BYTES-eng": "79414272", - "NUMBER_OF_FRAMES-eng": "44316", - "SOURCE_ID-eng": "001100", - "_STATISTICS_TAGS-eng": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES SOURCE_ID", - "_STATISTICS_WRITING_DATE_UTC-eng": "2021-04-21 20:00:45", - "language": "eng", - "title": "Surround 5.1", - }, - "time_base": "1/1000", - } - ), - AudioMatch( - match_type=MatchType.ALL, - match_item=MatchItem.LANGUAGE, - match_input="eng", - conversion=None, - bitrate="32k", - downmix="No Downmix", - ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].codec_name == "ac3" + assert result[0][0].index == 2 + + +def test_audio_filters_codec_match_last(): + """Test matching last audio track by codec name.""" + filters = [ + AudioMatch( + match_type=MatchType.LAST, + match_item=MatchItem.CODEC, + match_input="ac3", ), - ( - Box( - { - "avg_frame_rate": "0/0", - "bit_rate": "192000", - "bits_per_sample": 0, - "channel_layout": "stereo", - "channels": 2, - "codec_long_name": "ATSC A/52A (AC-3)", - "codec_name": "ac3", - "codec_tag": "0x0000", - "codec_tag_string": "[0][0][0][0]", - "codec_type": "audio", - "disposition": { - "attached_pic": 0, - "captions": 0, - "clean_effects": 0, - "comment": 0, - "default": 0, - "dependent": 0, - "descriptions": 0, - "dub": 0, - "forced": 0, - "hearing_impaired": 0, - "karaoke": 0, - "lyrics": 0, - "metadata": 0, - "original": 0, - "still_image": 0, - "timed_thumbnails": 0, - "visual_impaired": 0, - }, - "index": 4, - "r_frame_rate": "0/0", - "sample_fmt": "fltp", - "sample_rate": "48000", - "start_pts": 0, - "start_time": "0.000000", - "tags": { - "BPS-eng": "192000", - "DURATION-eng": "00:23:38.112000000", - "NUMBER_OF_BYTES-eng": "34034688", - "NUMBER_OF_FRAMES-eng": "44316", - "SOURCE_ID-eng": "001101", - "_STATISTICS_TAGS-eng": "BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES SOURCE_ID", - "_STATISTICS_WRITING_DATE_UTC-eng": "2021-04-21 20:00:45", - "language": "jpn", - "title": "Stereo", - }, - "time_base": "1/1000", - } - ), - AudioMatch( - match_type=MatchType.LAST, - match_item=MatchItem.ALL, - match_input="*", - conversion=None, - bitrate="32k", - downmix="No Downmix", - ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].codec_name == "ac3" + assert result[0][0].index == 4 + + +def test_audio_filters_codec_match_all_dts(): + """Test matching all DTS tracks by codec name.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC, + match_input="dts", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 2 + assert result[0][0].codec_name == "dts" + assert result[1][0].codec_name == "dts" + + +def test_audio_filters_codec_match_case_insensitive(): + """Test that codec matching is case insensitive.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC, + match_input="TrueHD", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 2 + assert all(track.codec_name == "truehd" for track, _ in result) + + +def test_audio_filters_codec_profile_match(): + """Test matching audio tracks by codec and profile (e.g. DTS-HD MA vs DTS).""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC_PROFILE, + match_input="dts:DTS-HD MA", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].codec_name == "dts" + assert result[0][0].profile == "DTS-HD MA" + assert result[0][0].index == 5 + + +def test_audio_filters_codec_profile_match_regular_dts(): + """Test matching regular DTS (not DTS-HD MA) by codec and profile.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC_PROFILE, + match_input="dts:DTS", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].codec_name == "dts" + assert result[0][0].profile == "DTS" + assert result[0][0].index == 6 + + +def test_audio_filters_codec_profile_case_insensitive(): + """Test that codec:profile matching is case insensitive.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC_PROFILE, + match_input="DTS:dts-hd ma", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].profile == "DTS-HD MA" + + +def test_audio_filters_codec_profile_no_match(): + """Test codec:profile matching returns empty when no match.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC_PROFILE, + match_input="dts:DTS-HD HRA", ), ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 0 + - assert result == expected_result, result +def test_audio_filters_codec_profile_truehd_atmos(): + """Test matching TrueHD+Atmos profile.""" + filters = [ + AudioMatch( + match_type=MatchType.ALL, + match_item=MatchItem.CODEC_PROFILE, + match_input="truehd:TrueHD+Atmos", + ), + ] + result = apply_audio_filters(audio_filters=filters, original_tracks=test_audio_tracks) + assert len(result) == 1 + assert result[0][0].codec_name == "truehd" + assert result[0][0].profile == "TrueHD+Atmos" class TestAudioMatchValidator: diff --git a/tests/test_auto_crop_parsing.py b/tests/test_auto_crop_parsing.py new file mode 100644 index 00000000..98d032a0 --- /dev/null +++ b/tests/test_auto_crop_parsing.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Tests for auto-crop detection parsing logic.""" + +from fastflix.flix import parse_cropdetect_output + + +def make_cropdetect_line(w, h, x, y): + """Generate a realistic FFmpeg cropdetect output line.""" + return f"[Parsed_cropdetect_0 @ 0x55b8c0] x1:{x} x2:{x + w - 1} y1:{y} y2:{y + h - 1} w:{w} h:{h} x:{x} y:{y} pts:1001 t:1.001000 limit:0.094118 crop={w}:{h}:{x}:{y}" + + +def make_stderr(*detections): + """Build a full stderr string from multiple (w, h, x, y) tuples.""" + lines = ["Input #0, matroska,webm, from 'test.mkv':"] + for d in detections: + lines.append(make_cropdetect_line(*d)) + return "\n".join(lines) + + +class TestParseCropdetectOutput: + """Test parse_cropdetect_output with various aspect ratios and scenarios.""" + + def test_16_9_letterbox_235_1(self): + # 2.35:1 content in 1920x1080: top/bottom bars ~130px each + # Content is ~1920x820 + stderr = make_stderr( + (1920, 820, 0, 130), + (1920, 818, 0, 131), + (1920, 820, 0, 130), + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + # Most conservative = largest area = 1920*820 + assert result == [0, 130, 0, 130] # [right, bottom, left, top] + + def test_4_3_pillarbox_in_16_9(self): + # 4:3 content (1440x1080) in 1920x1080 frame with pillarboxing + stderr = make_stderr( + (1440, 1080, 240, 0), + (1438, 1080, 241, 0), + (1440, 1080, 240, 0), + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + # Most conservative = 1440*1080 (largest area) + assert result == [240, 0, 240, 0] + + def test_ultrawide_276_1(self): + # 2.76:1 content (1920x696) in 1920x1080 frame + stderr = make_stderr( + (1920, 696, 0, 192), + (1920, 694, 0, 193), + (1920, 696, 0, 192), + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result == [0, 192, 0, 192] + + def test_no_crop_needed(self): + # Content fills entire frame + stderr = make_stderr( + (1920, 1080, 0, 0), + (1920, 1080, 0, 0), + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result == [0, 0, 0, 0] + + def test_zero_offset_preserved(self): + # x=0 and y=0 are valid offsets that should NOT be overwritten + stderr = make_stderr( + (1440, 1080, 240, 0), # x=240, y=0 (y=0 is correct) + (1438, 1078, 241, 1), # Noisy: y=1 is wrong + (1440, 1080, 240, 0), + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + # Should pick 1440*1080 (largest area), NOT mix y from frame 2 + assert result == [240, 0, 240, 0] + + def test_noisy_detections_picks_conservative(self): + # Varying detections — should pick largest content area + stderr = make_stderr( + (1900, 1060, 10, 10), # Slightly aggressive + (1910, 1070, 5, 5), # Less aggressive + (1920, 1080, 0, 0), # No crop (most conservative) + ) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result == [0, 0, 0, 0] # Picks the full frame + + def test_empty_stderr(self): + result = parse_cropdetect_output("", 1920, 1080) + assert result is None + + def test_no_cropdetect_lines(self): + stderr = "Some other ffmpeg output\nNo cropdetect lines here" + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result is None + + def test_malformed_cropdetect_line(self): + # One bad line mixed with good ones + stderr = "[Parsed_cropdetect_0 @ 0x55b8c0] malformed=garbage\n" + make_cropdetect_line(1920, 800, 0, 140) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result == [0, 140, 0, 140] + + def test_single_detection(self): + stderr = make_stderr((1440, 1080, 240, 0)) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result == [240, 0, 240, 0] + + def test_negative_margin_returns_none(self): + # Crop detection reports content larger than frame (shouldn't happen, but guard) + stderr = make_stderr((2000, 1080, 0, 0)) + result = parse_cropdetect_output(stderr, 1920, 1080) + assert result is None # right = 1920 - 2000 - 0 = -80 + + def test_4k_source(self): + # 4K with letterbox + stderr = make_stderr( + (3840, 1600, 0, 280), + (3840, 1602, 0, 279), + ) + result = parse_cropdetect_output(stderr, 3840, 2160) + # Most conservative: 3840*1602 > 3840*1600 + assert result == [0, 279, 0, 279] diff --git a/tests/test_data_panel.py b/tests/test_data_panel.py index d771fc6a..c95a7c81 100644 --- a/tests/test_data_panel.py +++ b/tests/test_data_panel.py @@ -114,11 +114,65 @@ def test_multiple_data_streams(self): assert result[idx + 1] == "1,2" +class TestBuildDataRigayaOutputFormat: + """Rigaya encoders can only copy data/attachment streams to MKV containers.""" + + def test_mp4_output_skips_data_copy(self): + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, [], output_path=Path("output.mp4")) + assert result == [] + + def test_mov_output_skips_data_copy(self): + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, [], output_path=Path("output.mov")) + assert result == [] + + def test_mkv_output_allows_data_copy(self): + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, [], output_path=Path("output.mkv")) + assert "--data-copy" in result + + def test_mkv_output_allows_attachment_copy(self): + tracks = [DataTrack(index=10, outdex=5, enabled=True, codec_type="attachment")] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, [], attachment_streams, output_path=Path("output.mkv")) + assert "--attachment-copy" in result + + def test_mp4_output_skips_attachment_copy(self): + tracks = [DataTrack(index=10, outdex=5, enabled=True, codec_type="attachment")] + attachment_streams = [Box({"index": 10})] + result = build_data(tracks, [], attachment_streams, output_path=Path("output.mp4")) + assert result == [] + + def test_ts_output_skips_data_copy(self): + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, [], output_path=Path("output.ts")) + assert result == [] + + def test_no_output_path_allows_data_copy(self): + """Backward compatibility: no output_path means no filtering.""" + tracks = [DataTrack(index=5, outdex=3, enabled=True, codec_type="data")] + data_streams = [Box({"index": 5})] + result = build_data(tracks, data_streams, []) + assert "--data-copy" in result + + class TestGenerateEndingWithDataTracks: def test_with_data_tracks(self): tracks = [ DataTrack(index=5, outdex=3, enabled=True, codec_type="data"), - DataTrack(index=10, outdex=4, enabled=True, codec_type="attachment"), + DataTrack( + index=10, + outdex=4, + enabled=True, + codec_type="attachment", + mimetype="application/x-truetype-font", + filename="test_font.ttf", + ), ] result, _ = generate_ending( audio=[], @@ -132,6 +186,35 @@ def test_with_data_tracks(self): assert "-c:d" in result assert "copy" in result assert "-c:t" in result + assert "-metadata:s:4" in result + assert "mimetype=application/x-truetype-font" in result + assert "filename=test_font.ttf" in result + # Both tracks should have title/handler cleared + assert "-metadata:s:3" in result + assert "title=" in result + assert "handler=" in result + + def test_data_track_title_and_handler_metadata(self): + """Data/attachment tracks must set title and handler metadata (empty string when not set).""" + tracks = [ + DataTrack(index=5, outdex=2, enabled=True, codec_type="data", title="Timecode"), + DataTrack(index=6, outdex=3, enabled=True, codec_type="data", title=""), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mp4"), + data_tracks=tracks, + ) + result_str = " ".join(result) + # Track with title should have it set + assert "-metadata:s:2" in result + assert "title=Timecode" in result_str + assert "handler=Timecode" in result_str + # Track without title should have empty strings + assert "-metadata:s:3" in result + assert "title=" in result + assert "handler=" in result def test_with_disabled_data_tracks(self): tracks = [ @@ -183,7 +266,16 @@ def test_no_data(self): def test_attachment_only_codec(self): """When only attachment tracks, should set -c:t copy but not -c:d.""" - tracks = [DataTrack(index=10, outdex=4, enabled=True, codec_type="attachment")] + tracks = [ + DataTrack( + index=10, + outdex=4, + enabled=True, + codec_type="attachment", + mimetype="application/x-truetype-font", + filename="test_font.ttf", + ) + ] result, _ = generate_ending( audio=[], subtitles=[], @@ -192,3 +284,91 @@ def test_attachment_only_codec(self): ) assert "-c:t" in result assert "-c:d" not in result + assert "mimetype=application/x-truetype-font" in result + assert "filename=test_font.ttf" in result + + def test_attachment_mimetype_restored_after_metadata_strip(self): + """Mimetype and filename must be emitted per-stream so matroska muxer accepts attachments.""" + tracks = [ + DataTrack( + index=3, + outdex=2, + enabled=True, + codec_type="attachment", + mimetype="application/x-truetype-font", + filename="test_font.ttf", + ), + DataTrack( + index=5, + outdex=3, + enabled=True, + codec_type="attachment", + mimetype="application/json", + filename="metadata.json", + ), + DataTrack( + index=6, + outdex=4, + enabled=True, + codec_type="attachment", + mimetype="application/octet-stream", + filename="test_data.bin", + ), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + remove_metadata=True, + ) + # -map_metadata -1 strips metadata, so per-stream mimetype must be restored + assert "-map_metadata" in result + # Font attachment + assert "-metadata:s:2" in result + assert "mimetype=application/x-truetype-font" in result + assert "filename=test_font.ttf" in result + # JSON attachment + assert "-metadata:s:3" in result + assert "mimetype=application/json" in result + assert "filename=metadata.json" in result + # Binary attachment + assert "-metadata:s:4" in result + assert "mimetype=application/octet-stream" in result + assert "filename=test_data.bin" in result + + def test_attachment_without_mimetype_no_metadata(self): + """Attachments without mimetype/filename should not emit empty metadata.""" + tracks = [ + DataTrack(index=3, outdex=2, enabled=True, codec_type="attachment"), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + ) + assert "-c:t" in result + assert "mimetype=" not in " ".join(result) + assert "filename=" not in " ".join(result) + + def test_data_tracks_no_mimetype_for_data_type(self): + """Data streams (not attachments) should never get mimetype metadata.""" + tracks = [ + DataTrack( + index=5, + outdex=3, + enabled=True, + codec_type="data", + mimetype="application/octet-stream", + filename="some_data.bin", + ), + ] + result, _ = generate_ending( + audio=[], + subtitles=[], + output_video=Path("output.mkv"), + data_tracks=tracks, + ) + assert "-c:d" in result + assert "mimetype=" not in " ".join(result) diff --git a/tests/test_keep_source_setting.py b/tests/test_keep_source_setting.py new file mode 100644 index 00000000..2652cf8e --- /dev/null +++ b/tests/test_keep_source_setting.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +Tests for the keep_source_after_encode setting (#677). + +Verifies: +- Config field defaults to False +- Config round-trips through serialization +- add_to_queue() clears video when setting is off (default) +- add_to_queue() keeps video when setting is on +""" + +from pathlib import Path +from unittest import mock + +import pytest + +from fastflix.models.config import Config + + +@pytest.fixture +def config(): + return Config( + version="4.0.0", + ffmpeg=Path("ffmpeg"), + ffprobe=Path("ffprobe"), + work_path=Path("work_path"), + ) + + +class TestConfigField: + def test_default_is_false(self, config): + assert config.keep_source_after_encode is False + + def test_can_set_true(self, config): + config.keep_source_after_encode = True + assert config.keep_source_after_encode is True + + def test_round_trip_model_dump(self, config): + config.keep_source_after_encode = True + data = config.model_dump() + assert data["keep_source_after_encode"] is True + + def test_round_trip_model_dump_default(self, config): + data = config.model_dump() + assert data["keep_source_after_encode"] is False + + +class TestAddToQueueBehavior: + """Test that add_to_queue respects the keep_source_after_encode setting.""" + + @pytest.fixture + def mock_main(self, config): + """Create a mock Main widget with the encoding mixin behavior.""" + main = mock.MagicMock() + main.app.fastflix.config = config + main.app.fastflix.current_video = mock.MagicMock() + main.video_options.queue.add_to_queue.return_value = None + return main + + def test_clears_video_when_setting_off(self, mock_main): + """Default behavior: clear source after adding to queue.""" + mock_main.app.fastflix.config.keep_source_after_encode = False + + from fastflix.widgets.main_encoding import EncodingMixin + + mixin = EncodingMixin() + mixin.__dict__.update( + { + "app": mock_main.app, + "video_options": mock_main.video_options, + "clear_current_video": mock_main.clear_current_video, + } + ) + result = EncodingMixin.add_to_queue(mixin) + + assert result is True + mock_main.clear_current_video.assert_called_once() + + def test_keeps_video_when_setting_on(self, mock_main): + """With setting enabled: keep source after adding to queue.""" + mock_main.app.fastflix.config.keep_source_after_encode = True + + from fastflix.widgets.main_encoding import EncodingMixin + + mixin = EncodingMixin() + mixin.__dict__.update( + { + "app": mock_main.app, + "video_options": mock_main.video_options, + "clear_current_video": mock_main.clear_current_video, + } + ) + result = EncodingMixin.add_to_queue(mixin) + + assert result is True + mock_main.clear_current_video.assert_not_called() + + def test_no_clear_on_error(self, mock_main): + """If add_to_queue raises, clear_current_video should not be called.""" + from fastflix.exceptions import FastFlixInternalException + from fastflix.widgets.main_encoding import EncodingMixin + + mock_main.video_options.queue.add_to_queue.side_effect = FastFlixInternalException("test error") + + mixin = EncodingMixin() + mixin.__dict__.update( + { + "app": mock_main.app, + "video_options": mock_main.video_options, + "clear_current_video": mock_main.clear_current_video, + } + ) + + with mock.patch("fastflix.widgets.main_encoding.error_message"): + result = EncodingMixin.add_to_queue(mixin) + + assert result is None + mock_main.clear_current_video.assert_not_called() + + def test_no_clear_when_queue_returns_code(self, mock_main): + """If queue.add_to_queue returns a non-None code, clear is not reached.""" + from fastflix.widgets.main_encoding import EncodingMixin + + mock_main.video_options.queue.add_to_queue.return_value = False + + mixin = EncodingMixin() + mixin.__dict__.update( + { + "app": mock_main.app, + "video_options": mock_main.video_options, + "clear_current_video": mock_main.clear_current_video, + } + ) + result = EncodingMixin.add_to_queue(mixin) + + assert result is False + mock_main.clear_current_video.assert_not_called() diff --git a/tests/test_local_encode.py b/tests/test_local_encode.py index 118a71a6..99b9abdf 100644 --- a/tests/test_local_encode.py +++ b/tests/test_local_encode.py @@ -29,7 +29,10 @@ from fastflix.models.config import Config from fastflix.models.encode import ( AOMAV1Settings, + AttachmentTrack, + AudioTrack, CopySettings, + DataTrack, FFmpegNVENCSettings, GIFSettings, GifskiSettings, @@ -68,6 +71,8 @@ # Paths # --------------------------------------------------------------------------- TEST_SOURCE = Path(__file__).parent / "media" / "Beverly Hills Duck Pond - HDR10plus - Jessica Payne.mp4" +TEST_SOURCE_ATTACHMENTS = Path(__file__).parent / "media" / "font_attachment_data.mkv" +TEST_SOURCE_CHAPTERS = Path(__file__).parent / "media" / "chapters_timecode.mp4" FFMPEG = shutil.which("ffmpeg") FFPROBE = shutil.which("ffprobe") @@ -490,3 +495,360 @@ def test_encode(encoder_id, settings, output_ext, expected_codec, is_rigaya, tmp run_commands(commands, work_path) verify_output(output_path, expected_codec) + + +# =========================================================================== +# Attachment / font preservation test +# =========================================================================== +def _probe_attachment_source() -> Optional[dict]: + if not FFPROBE or not TEST_SOURCE_ATTACHMENTS.exists(): + return None + try: + result = subprocess.run( + [ + FFPROBE, + "-v", + "quiet", + "-print_format", + "json", + "-show_streams", + "-show_format", + str(TEST_SOURCE_ATTACHMENTS), + ], + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(result.stdout) + except Exception: + return None + + +def test_encode_preserves_attachments_and_cover(tmp_path): + """Encode MKV with font, cover, and data attachments → verify all are in output.""" + if ON_CI: + pytest.skip("Skipped on CI") + if not FFMPEG or not FFPROBE: + pytest.skip("ffmpeg/ffprobe not found") + if not _has_ffmpeg_encoder("libx265"): + pytest.skip("libx265 not available") + if not TEST_SOURCE_ATTACHMENTS.exists(): + pytest.skip("font_attachment_data.mkv not found") + + probe = _probe_attachment_source() + assert probe is not None, "Could not probe attachment test source" + + video_streams = [ + s + for s in probe["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0 + ] + audio_streams = [s for s in probe["streams"] if s["codec_type"] == "audio"] + attachment_streams = [s for s in probe["streams"] if s.get("codec_type") == "attachment"] + + for stream in video_streams: + if "bits_per_raw_sample" in stream: + stream["bit_depth"] = int(stream["bits_per_raw_sample"]) + else: + stream["bit_depth"] = guess_bit_depth(stream.get("pix_fmt", ""), stream.get("color_primaries")) + + streams = Box( + { + "video": [Box(v) for v in video_streams], + "audio": [Box(a) for a in audio_streams], + "subtitle": [], + "data": [], + "attachment": [Box(a) for a in attachment_streams], + } + ) + + output_path = tmp_path / "output_attachments.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + # Extract cover image to work_path (simulates what FastFlix does) + cover_path = work_path / "cover.png" + subprocess.run( + [FFMPEG, "-y", "-i", str(TEST_SOURCE_ATTACHMENTS), "-map", "0:4", "-c", "copy", str(cover_path)], + capture_output=True, + timeout=30, + ) + + video_settings = VideoSettings( + remove_hdr=False, + output_path=output_path, + end_time=2, + ) + video_settings.video_encoder_settings = x265Settings(preset="ultrafast", crf=51) + + video = Video( + source=TEST_SOURCE_ATTACHMENTS, + duration=10.0, + streams=streams, + format=Box(probe.get("format", {})), + video_settings=video_settings, + work_path=work_path, + ) + + # Audio track (stream 1: aac, copy) + # outdex: 1 (after video at 0) + video.audio_tracks = [ + AudioTrack( + index=1, + outdex=1, + codec="aac", + enabled=True, + raw_info=Box({"channel_layout": "mono", "channels": 1, "codec_name": "aac"}), + ), + ] + + # Set up data tracks (font + json + binary attachments from source) + # outdex starts at 2 (after video=0, audio=1) + # Stream 3: font (ttf), Stream 5: json attachment, Stream 6: binary attachment + video.data_tracks = [ + DataTrack( + index=3, + outdex=2, + enabled=True, + codec_type="attachment", + codec_name="ttf", + mimetype="application/x-truetype-font", + filename="test_font.ttf", + ), + DataTrack( + index=5, + outdex=3, + enabled=True, + codec_type="attachment", + codec_name="", + mimetype="application/json", + filename="metadata.json", + ), + DataTrack( + index=6, + outdex=4, + enabled=True, + codec_type="attachment", + codec_name="", + mimetype="application/octet-stream", + filename="test_data.bin", + ), + ] + + # Cover attachment via -attach (FFmpeg places after all -map streams) + # outdex: 5 (after video=0, audio=1, 3 data tracks=2,3,4) + video.attachment_tracks = [ + AttachmentTrack( + index=4, + outdex=5, + file_path=str(cover_path), + filename="cover", + attachment_type="cover", + ), + ] + + config = Config( + version="4.0.0", + ffmpeg=Path(FFMPEG), + ffprobe=Path(FFPROBE), + work_path=work_path, + ) + + fastflix = FastFlix( + config=config, + encoders={}, + audio_encoders=[], + current_video=video, + ffmpeg_version="n5.0", + ) + + # Build and run + from fastflix.encoders.hevc_x265 import command_builder + + commands = command_builder.build(fastflix) + assert commands, "Encoder returned no commands" + run_commands(commands, work_path) + + # Verify output exists + assert output_path.exists(), "Output file does not exist" + assert output_path.stat().st_size > 0, "Output file is empty" + + # Probe output and verify all streams + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", str(output_path)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"ffprobe failed: {result.stderr}" + output_data = json.loads(result.stdout) + + out_video = [ + s + for s in output_data["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0 + ] + out_audio = [s for s in output_data["streams"] if s["codec_type"] == "audio"] + out_attachments = [s for s in output_data["streams"] if s["codec_type"] == "attachment"] + out_covers = [ + s + for s in output_data["streams"] + if s["codec_type"] == "video" and s.get("disposition", {}).get("attached_pic", 0) == 1 + ] + + # Video and audio preserved + assert len(out_video) >= 1, "No video stream in output" + assert out_video[0]["codec_name"] == "hevc" + assert len(out_audio) >= 1, "No audio stream in output" + + # Font attachment preserved with correct mimetype + font_attachments = [ + s for s in out_attachments if s.get("tags", {}).get("mimetype") == "application/x-truetype-font" + ] + assert len(font_attachments) >= 1, f"Font attachment missing from output. Attachments found: {out_attachments}" + + # JSON and binary attachments preserved + json_attachments = [s for s in out_attachments if s.get("tags", {}).get("filename") == "metadata.json"] + assert len(json_attachments) >= 1, f"JSON attachment missing from output. Attachments found: {out_attachments}" + + bin_attachments = [s for s in out_attachments if s.get("tags", {}).get("filename") == "test_data.bin"] + assert len(bin_attachments) >= 1, f"Binary attachment missing from output. Attachments found: {out_attachments}" + + # Cover image preserved + assert len(out_covers) >= 1, ( + f"Cover image missing from output. All streams: {[s['codec_type'] for s in output_data['streams']]}" + ) + + +# =========================================================================== +# Data stream exclusion test (MKV can't hold data streams) +# =========================================================================== +def test_encode_excludes_data_streams_for_mkv(tmp_path): + """Encode MP4 with data stream (timecode) to MKV — data must be excluded, not cause failure.""" + if ON_CI: + pytest.skip("Skipped on CI") + if not FFMPEG or not FFPROBE: + pytest.skip("ffmpeg/ffprobe not found") + if not _has_ffmpeg_encoder("libx265"): + pytest.skip("libx265 not available") + if not TEST_SOURCE_CHAPTERS.exists(): + pytest.skip("chapters_timecode.mp4 not found") + + probe_result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(TEST_SOURCE_CHAPTERS)], + capture_output=True, + text=True, + timeout=30, + ) + probe = json.loads(probe_result.stdout) + + video_streams = [s for s in probe["streams"] if s["codec_type"] == "video"] + audio_streams = [s for s in probe["streams"] if s["codec_type"] == "audio"] + data_streams = [s for s in probe["streams"] if s["codec_type"] == "data"] + + assert len(data_streams) >= 1, "Test file should have at least one data stream" + + for stream in video_streams: + if "bits_per_raw_sample" in stream: + stream["bit_depth"] = int(stream["bits_per_raw_sample"]) + else: + stream["bit_depth"] = guess_bit_depth(stream.get("pix_fmt", ""), stream.get("color_primaries")) + + streams = Box( + { + "video": [Box(v) for v in video_streams], + "audio": [Box(a) for a in audio_streams], + "subtitle": [], + "data": [Box(d) for d in data_streams], + "attachment": [], + } + ) + + output_path = tmp_path / "output_no_data.mkv" + work_path = tmp_path / "work" + work_path.mkdir() + + video_settings = VideoSettings( + remove_hdr=False, + output_path=output_path, + end_time=2, + ) + video_settings.video_encoder_settings = x265Settings(preset="ultrafast", crf=51) + + video = Video( + source=TEST_SOURCE_CHAPTERS, + duration=10.0, + streams=streams, + format=Box(probe.get("format", {})), + video_settings=video_settings, + work_path=work_path, + ) + + # Audio track (copy) + video.audio_tracks = [ + AudioTrack( + index=1, + outdex=1, + codec="aac", + enabled=True, + raw_info=Box({"channel_layout": "mono", "channels": 1, "codec_name": "aac"}), + ), + ] + + # Data track exists but is DISABLED (incompatible with MKV) + video.data_tracks = [ + DataTrack( + index=2, + outdex=2, + enabled=False, + codec_type="data", + codec_name="bin_data", + title="", + ), + ] + + config = Config( + version="4.0.0", + ffmpeg=Path(FFMPEG), + ffprobe=Path(FFPROBE), + work_path=work_path, + ) + + fastflix = FastFlix( + config=config, + encoders={}, + audio_encoders=[], + current_video=video, + ffmpeg_version="n5.0", + ) + + # Build and run — this should NOT fail even though source has a data stream + from fastflix.encoders.hevc_x265 import command_builder + + commands = command_builder.build(fastflix) + assert commands, "Encoder returned no commands" + + # Verify -dn is in the command (explicitly disables data streams) + cmd_str = commands[0].to_string() + assert "-dn" in cmd_str, f"Expected -dn in command to exclude data streams: {cmd_str}" + + run_commands(commands, work_path) + + # Verify output + assert output_path.exists(), "Output file does not exist" + result = subprocess.run( + [FFPROBE, "-v", "quiet", "-print_format", "json", "-show_streams", str(output_path)], + capture_output=True, + text=True, + timeout=30, + ) + output_data = json.loads(result.stdout) + + out_video = [s for s in output_data["streams"] if s["codec_type"] == "video"] + out_audio = [s for s in output_data["streams"] if s["codec_type"] == "audio"] + out_data = [s for s in output_data["streams"] if s["codec_type"] == "data"] + + assert len(out_video) >= 1, "No video stream in output" + assert out_video[0]["codec_name"] == "hevc" + assert len(out_audio) >= 1, "No audio stream in output" + assert len(out_data) == 0, f"Data streams should be excluded from MKV output, found: {out_data}" diff --git a/tests/test_output_dimensions.py b/tests/test_output_dimensions.py new file mode 100644 index 00000000..0973803c --- /dev/null +++ b/tests/test_output_dimensions.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Tests for Video.compute_output_dimensions and related properties.""" + +from pathlib import Path + +from box import Box + +from fastflix.models.video import Video, VideoSettings, Crop + + +class TestComputeOutputDimensions: + """Test the static compute_output_dimensions method.""" + + def test_auto_returns_none(self): + assert Video.compute_output_dimensions(1920, 1080, method="auto") == (None, None) + + def test_auto_with_custom_returns_none(self): + assert Video.compute_output_dimensions(1920, 1080, method="auto", custom="1280") == (None, None) + + def test_no_custom_returns_none(self): + assert Video.compute_output_dimensions(1920, 1080, method="width", custom=None) == (None, None) + + def test_empty_custom_returns_none(self): + assert Video.compute_output_dimensions(1920, 1080, method="width", custom="") == (None, None) + + # Width scaling + def test_width_scaling_16_9(self): + w, h = Video.compute_output_dimensions(3840, 2160, method="width", custom="1920") + assert w == 1920 + assert h == 1080 + + def test_width_scaling_4_3(self): + w, h = Video.compute_output_dimensions(1440, 1080, method="width", custom="720") + assert w == 720 + assert h == 536 # 1080*720/1440=540, rounded to nearest 8 = 536 + assert h % 8 == 0 + + def test_width_scaling_ultrawide(self): + w, h = Video.compute_output_dimensions(1920, 696, method="width", custom="1280") + assert w == 1280 + assert h % 8 == 0 + + # Height scaling + def test_height_scaling_16_9(self): + w, h = Video.compute_output_dimensions(3840, 2160, method="height", custom="1080") + assert w == 1920 + assert h == 1080 + + def test_height_scaling_4_3(self): + w, h = Video.compute_output_dimensions(1440, 1080, method="height", custom="540") + assert w == 720 + assert h == 540 + assert w % 8 == 0 + + # Long edge scaling + def test_long_edge_landscape(self): + w, h = Video.compute_output_dimensions(3840, 2160, method="long edge", custom="1920") + assert w == 1920 + assert h == 1080 + + def test_long_edge_portrait(self): + w, h = Video.compute_output_dimensions(2160, 3840, method="long edge", custom="1920") + assert w == 1080 + assert h == 1920 + + def test_long_edge_square(self): + w, h = Video.compute_output_dimensions(1000, 1000, method="long edge", custom="500") + assert w == 500 + assert h == 496 # 500 rounded down to nearest multiple of 8 + + # Custom scaling + def test_custom_explicit(self): + w, h = Video.compute_output_dimensions(3840, 2160, method="custom", custom="1280:720") + assert w == 1280 + assert h == 720 + + def test_custom_invalid_format(self): + assert Video.compute_output_dimensions(1920, 1080, method="custom", custom="1280") == (None, None) + + def test_custom_zero_dimension(self): + assert Video.compute_output_dimensions(1920, 1080, method="custom", custom="0:720") == (None, None) + + # Rounding + def test_rounding_to_multiple_of_8(self): + # 1920x800 -> width=1280 -> height = 800*1280/1920 = 533.3 -> floor to 528 + w, h = Video.compute_output_dimensions(1920, 800, method="width", custom="1280") + assert w == 1280 + assert h % 8 == 0 + + def test_minimum_dimension_is_8(self): + # Very extreme downscale — ensure minimum of 8 + w, h = Video.compute_output_dimensions(3840, 2160, method="width", custom="16") + assert w == 16 + assert h >= 8 + assert h % 8 == 0 + + # Crop + def test_crop_affects_aspect_ratio(self): + # 1920x1080, crop 100 from each side -> 1720x880 + # Scale width to 860 -> h = 880*860/1720 = 440 + w, h = Video.compute_output_dimensions( + 1920, + 1080, + crop_top=100, + crop_bottom=100, + crop_left=100, + crop_right=100, + method="width", + custom="860", + ) + assert w == 860 + assert h == 440 + assert h % 8 == 0 + + def test_crop_pillarbox_4_3_in_16_9(self): + # 1920x1080 with 240px pillarbox on each side -> content is 1440x1080 + w, h = Video.compute_output_dimensions( + 1920, + 1080, + crop_left=240, + crop_right=240, + method="width", + custom="720", + ) + assert w == 720 + # 1080*720/1440 = 540 + assert h == 536 # rounded to multiple of 8 + assert h % 8 == 0 + + def test_crop_letterbox_ultrawide(self): + # 1920x1080 with letterbox top/bottom -> content is 1920x696 + w, h = Video.compute_output_dimensions( + 1920, + 1080, + crop_top=192, + crop_bottom=192, + method="width", + custom="1920", + ) + assert w == 1920 + assert h == 696 + assert h % 8 == 0 + + def test_crop_exceeds_source_returns_none(self): + assert Video.compute_output_dimensions( + 1920, + 1080, + crop_left=1000, + crop_right=1000, + method="width", + custom="100", + ) == (None, None) + + # Invalid inputs + def test_invalid_custom_string(self): + assert Video.compute_output_dimensions(1920, 1080, method="width", custom="abc") == (None, None) + + def test_negative_pixels(self): + assert Video.compute_output_dimensions(1920, 1080, method="width", custom="-100") == (None, None) + + def test_zero_pixels(self): + assert Video.compute_output_dimensions(1920, 1080, method="width", custom="0") == (None, None) + + +class TestVideoOutputProperties: + """Test the Video convenience properties that use compute_output_dimensions.""" + + @staticmethod + def make_video(width=1920, height=1080, crop=None, method="auto", custom=None): + vs = VideoSettings( + crop=crop, + resolution_method=method, + resolution_custom=custom, + ) + return Video( + source=Path("test.mkv"), + duration=60, + streams=Box({"video": [Box({"index": 0, "width": width, "height": height})]}), + format=Box({}), + video_settings=vs, + work_path=Path("work"), + ) + + def test_cropped_width_no_crop(self): + v = self.make_video() + assert v.cropped_width == 1920 + + def test_cropped_width_with_crop(self): + v = self.make_video(crop=Crop(left=100, right=100)) + assert v.cropped_width == 1720 + + def test_cropped_height_with_crop(self): + v = self.make_video(crop=Crop(top=50, bottom=50)) + assert v.cropped_height == 980 + + def test_output_width_auto(self): + v = self.make_video() + assert v.output_width is None + assert v.output_height is None + + def test_output_width_with_scale(self): + v = self.make_video(method="width", custom="960") + assert v.output_width == 960 + assert v.output_height == 536 # 1080*960/1920 = 540 -> round to 536 + assert v.output_height % 8 == 0 + + def test_scale_property_auto(self): + v = self.make_video() + assert v.scale is None + + def test_scale_property_with_dimensions(self): + v = self.make_video(width=3840, height=2160, method="width", custom="1920") + assert v.scale == "1920:1080" + + def test_scale_property_with_crop(self): + v = self.make_video( + crop=Crop(left=240, right=240), + method="width", + custom="720", + ) + # Cropped: 1440x1080, scale width to 720 -> height = 1080*720/1440=540 -> round to 536 + assert v.scale == "720:536" diff --git a/uv.lock b/uv.lock index 905e7d92..f7cea569 100644 --- a/uv.lock +++ b/uv.lock @@ -178,6 +178,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/43/a363c213224448f9e194d626221123ce00e3fb3d87c0c22aed52b620bdd1/colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662", size = 11286, upload-time = "2022-08-29T14:51:26.426Z" }, ] +[[package]] +name = "deepl" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/48/7d46c6372b01a01d7b577841c3270812d6979e383b515dceb9298f1cbf10/deepl-1.29.0.tar.gz", hash = "sha256:494b1606c4885aa8a8e1f34fa516865c12e2bc27d6b159147f2ce06782e125ec", size = 54357, upload-time = "2026-04-01T09:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/61/f92e365f753abe2217276f7f4b38b36983e49886d4257320a047c493ff9e/deepl-1.29.0-py3-none-any.whl", hash = "sha256:23046778dfb173d22cf6306e6a7c182f3bfabfbdff9e52e746fc960c65c475ec", size = 48469, upload-time = "2026-04-01T09:31:33.907Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -187,6 +199,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastflix" source = { editable = "." } @@ -219,9 +240,11 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "deepl" }, { name = "pre-commit" }, { name = "pyinstaller" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "types-requests" }, { name = "types-setuptools" }, @@ -259,9 +282,11 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "deepl", specifier = ">=1.29.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pyinstaller", specifier = ">=6.13.0" }, { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-xdist", specifier = ">=3.5" }, { name = "ruff", specifier = ">=0.14" }, { name = "types-requests", specifier = ">=2.32" }, { name = "types-setuptools", specifier = ">=80.9" }, @@ -884,6 +909,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-box" version = "7.4.1"