#!/usr/bin/env python3 """ 3DE to Nuke Track Converter A professional tool for converting 3DEqualizer 2D tracks to Nuke Tracker nodes """ import argparse import sys import json from pathlib import Path from typing import List, Dict, Optional, Tuple from dataclasses import dataclass from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn from rich.prompt import Prompt, Confirm from rich.layout import Layout from rich.text import Text from rich import box from rich.style import Style console = Console() # ASCII Art Banner BANNER = """ ╔═════════════════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ __________.___________ ___ ___ _____ __________ _____ __________________ ║ ║ \______ \ \_____ \ / | \ / _ \ \____ / / _ \\______ \______ \ ║ ║ | | _/ |/ | \/ ~ \/ /_\ \ / / / /_\ \| _/| | \ ║ ║ | | \ / | \ Y / | \/ /_/ | \ | \| ` \ ║ ║ |______ /___\_______ /\___|_ /\____|__ /_______ \____|__ /____|_ /_______ / ║ ║ \/ \/ \/ \/ \/ \/ \/ \/ ║ ║ ║ ║ _________ .____ .___ ___________________ ________ .____ _________ ║ ║ \_ ___ \| | | | \__ ___/\_____ \ \_____ \ | | / _____/ ║ ║ / \ \/| | | | | | / | \ / | \| | \_____ \ ║ ║ \ \___| |___| | | | / | \/ | \ |___ / \ ║ ║ \______ /_______ \___| |____| \_______ /\_______ /_______ \/_______ / ║ ║ \/ \/ \/ \/ \/ \/ ║ ║ ║ ║ Convert 2d tracks from 3dequalizer to .nk 2d tracker nodes. ║ ╚═════════════════════════════════════════════════════════════════════════════════════╝ """ @dataclass class TrackPoint: """Represents a single track point""" frame: int x: float y: float @dataclass class Track: """Represents a complete track""" name: str frame_start: int points: List[TrackPoint] @property def frame_end(self) -> int: return max(p.frame for p in self.points) if self.points else self.frame_start @property def num_points(self) -> int: return len(self.points) class TrackConverter: """Main converter class""" def __init__(self, config: Dict): self.config = config self.tracks: List[Track] = [] def parse_3de_file(self, filepath: Path) -> bool: """Parse 3DE track file with robust error handling""" try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeElapsedColumn(), console=console ) as progress: task = progress.add_task("[cyan]Reading 3DE file...", total=100) with open(filepath, 'r') as f: lines = f.read().strip().split('\n') progress.update(task, advance=20) if not lines: console.print("[red]✗[/red] Error: File is empty", style="bold red") return False try: track_count = int(lines[0]) except ValueError: console.print(f"[red]✗[/red] Error: Invalid track count on line 1: '{lines[0]}'", style="bold red") return False progress.update(task, advance=10, description=f"[cyan]Parsing {track_count} tracks...") current_line = 1 parse_increment = 70 / max(track_count, 1) for i in range(track_count): try: # Parse track number if current_line >= len(lines): raise ValueError(f"Unexpected end of file at track {i+1}") track_num = lines[current_line].strip() current_line += 1 # Parse frame start if current_line >= len(lines): raise ValueError(f"Missing frame_start for track {i+1}") frame_start = int(lines[current_line]) current_line += 1 # Parse number of points if current_line >= len(lines): raise ValueError(f"Missing num_points for track {i+1}") num_points = int(lines[current_line]) current_line += 1 # Parse points points = [] for j in range(num_points): if current_line >= len(lines): raise ValueError(f"Missing point data for track {i+1}, point {j+1}") parts = lines[current_line].strip().split() if len(parts) < 3: raise ValueError(f"Invalid point data at line {current_line+1}: '{lines[current_line]}'") points.append(TrackPoint( frame=int(parts[0]), x=float(parts[1]), y=float(parts[2]) )) current_line += 1 # Create track with custom name if available, otherwise use track number track_name = self.config.get('name_prefix', '') + f"track_{track_num}" self.tracks.append(Track( name=track_name, frame_start=frame_start, points=points )) progress.update(task, advance=parse_increment) except (ValueError, IndexError) as e: console.print(f"[red]✗[/red] Error parsing track {i+1}: {str(e)}", style="bold red") return False progress.update(task, completed=100) return True except FileNotFoundError: console.print(f"[red]✗[/red] File not found: {filepath}", style="bold red") return False except Exception as e: console.print(f"[red]✗[/red] Unexpected error: {str(e)}", style="bold red") return False def transform_coordinates(self, track: Track) -> Track: """Transform coordinates based on config settings""" if self.config.get('input_space') == 'normalized': # Convert normalized (0-1) to pixels width = self.config.get('input_width', 2048) height = self.config.get('input_height', 1556) for point in track.points: point.x *= width point.y *= height if self.config.get('flip_y', False): height = self.config.get('input_height', 1556) for point in track.points: point.y = height - point.y return track def fill_gaps(self, track: Track) -> Track: """Fill missing frames in track""" if not self.config.get('fill_gaps', False) or not track.points: return track strategy = self.config.get('fill_strategy', 'last') filled_points = [] # Sort points by frame sorted_points = sorted(track.points, key=lambda p: p.frame) for i in range(len(sorted_points) - 1): current = sorted_points[i] next_point = sorted_points[i + 1] filled_points.append(current) # Check for gap gap = next_point.frame - current.frame if gap > 1: for frame in range(current.frame + 1, next_point.frame): if strategy == 'last': filled_points.append(TrackPoint(frame, current.x, current.y)) elif strategy == 'linear': # Linear interpolation t = (frame - current.frame) / gap x = current.x + (next_point.x - current.x) * t y = current.y + (next_point.y - current.y) * t filled_points.append(TrackPoint(frame, x, y)) filled_points.append(sorted_points[-1]) track.points = filled_points return track def generate_curve_data(self, points: List[TrackPoint], axis: str) -> str: """Generate Nuke curve data""" curve_data = '{curve' for point in points: value = point.x if axis == 'x' else point.y curve_data += f' x{point.frame} {value}' curve_data += '}' return curve_data def generate_nuke_file(self, output_path: Path) -> bool: """Generate Nuke .nk file""" try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), console=console ) as progress: task = progress.add_task("[cyan]Generating Nuke file...", total=len(self.tracks)) # Transform and process tracks processed_tracks = [] for track in self.tracks: track = self.transform_coordinates(track) track = self.fill_gaps(track) processed_tracks.append(track) progress.advance(task) # Generate track entries track_entries = [] for index, track in enumerate(processed_tracks): track_x = self.generate_curve_data(track.points, 'x') track_y = self.generate_curve_data(track.points, 'y') enabled = '1' if (self.config.get('enable_all', False) or index == 0) else '0' entry = f' {{ {{curve K x1 1}} "{track.name}" {track_x} {track_y} {{curve K x1 0}} {{curve K x1 0}} {enabled} 0 0 {{curve x1 0}} 1 0 -32 -32 32 32 -22 -22 22 22 {{}} {{}} {{}} {{}} {{}} {{}} {{}} {{}} {{}} {{}} {{}} }}' track_entries.append(entry) # Get config values nuke_version = self.config.get('nuke_version', '15.2 v3') format_name = self.config.get('format', '2K_Super_35(full-ap)') width = self.config.get('input_width', 2048) height = self.config.get('input_height', 1556) center_x = width // 2 center_y = height // 2 # Generate .nk file nuke_file = f"""#! C:/Program Files/Nuke15.2v3/nuke-15.2.3.dll -nx version {nuke_version} Root {{ inputs 0 name {output_path.name} format "{width} {height} 0 0 {width} {height} 1 {format_name}" proxy_type scale colorManagement OCIO OCIO_config fn-nuke_studio-config-v1.0.0_aces-v1.3_ocio-v2.1 defaultViewerLUT "OCIO LUTs" workingSpaceLUT scene_linear monitorLut "ACES 1.0 - SDR Video (sRGB - Display)" monitorOutLUT "ACES 1.0 - SDR Video (sRGB - Display)" }} Tracker4 {{ inputs 0 tracks {{ {{ 1 31 {len(processed_tracks)} }} {{ {{ 5 1 20 enable e 1 }} {{ 3 1 75 name name 1 }} {{ 2 1 58 track_x track_x 1 }} {{ 2 1 58 track_y track_y 1 }} {{ 2 1 63 offset_x offset_x 1 }} {{ 2 1 63 offset_y offset_y 1 }} {{ 4 1 27 T T 1 }} {{ 4 1 27 R R 1 }} {{ 4 1 27 S S 1 }} {{ 2 0 45 error error 1 }} {{ 1 1 0 error_min error_min 1 }} {{ 1 1 0 error_max error_max 1 }} {{ 1 1 0 pattern_x pattern_x 1 }} {{ 1 1 0 pattern_y pattern_y 1 }} {{ 1 1 0 pattern_r pattern_r 1 }} {{ 1 1 0 pattern_t pattern_t 1 }} {{ 1 1 0 search_x search_x 1 }} {{ 1 1 0 search_y search_y 1 }} {{ 1 1 0 search_r search_r 1 }} {{ 1 1 0 search_t search_t 1 }} {{ 2 1 0 key_track key_track 1 }} {{ 2 1 0 key_search_x key_search_x 1 }} {{ 2 1 0 key_search_y key_search_y 1 }} {{ 2 1 0 key_search_r key_search_r 1 }} {{ 2 1 0 key_search_t key_search_t 1 }} {{ 2 1 0 key_track_x key_track_x 1 }} {{ 2 1 0 key_track_y key_track_y 1 }} {{ 2 1 0 key_track_r key_track_r 1 }} {{ 2 1 0 key_track_t key_track_t 1 }} {{ 2 1 0 key_centre_offset_x key_centre_offset_x 1 }} {{ 2 1 0 key_centre_offset_y key_centre_offset_y 1 }} }} {{ {chr(10).join(track_entries)} }} }} center {{{center_x} {center_y}}} name Tracker1 xpos -75 ypos -94 }} """ with open(output_path, 'w') as f: f.write(nuke_file) return True except Exception as e: console.print(f"[red]✗[/red] Error generating Nuke file: {str(e)}", style="bold red") return False def display_summary(self): """Display conversion summary""" if not self.tracks: return # Create summary table table = Table( title="Track Summary", box=box.ROUNDED, show_header=True, header_style="bold magenta", border_style="cyan" ) table.add_column("Track", style="cyan", no_wrap=True) table.add_column("Points", justify="right", style="green") table.add_column("Frame Range", justify="center", style="yellow") table.add_column("Status", justify="center") for track in self.tracks[:10]: # Show first 10 status = "[green]✓[/green]" if track.num_points > 0 else "[red]✗[/red]" table.add_row( track.name, str(track.num_points), f"{track.frame_start}-{track.frame_end}", status ) if len(self.tracks) > 10: table.add_row("...", "...", "...", "...") console.print(table) # Stats panel total_points = sum(t.num_points for t in self.tracks) stats = f""" [cyan]Total Tracks:[/cyan] [bold]{len(self.tracks)}[/bold] [cyan]Total Points:[/cyan] [bold]{total_points}[/bold] [cyan]Avg Points/Track:[/cyan] [bold]{total_points // len(self.tracks) if self.tracks else 0}[/bold] """ console.print(Panel(stats, title="Statistics", border_style="green", box=box.ROUNDED)) def interactive_mode(): """Run in interactive mode with prompts""" console.print(Panel(BANNER, style="bold cyan", box=box.DOUBLE)) console.print("\n[bold cyan]Welcome to 3DE to Nuke Track Converter![/bold cyan]\n") # Get input file input_file = Prompt.ask( "[cyan]Enter 3DE track file path[/cyan] [dim](e.g., tracks.txt from 3DEqualizer)[/dim]", default="tracks.txt" ) if not Path(input_file).exists(): console.print(f"[red]✗[/red] File not found: {input_file}", style="bold red") return # Auto-generate output filename from input input_path = Path(input_file) default_output = input_path.stem + ".nk" # Get output file output_file = Prompt.ask( "[cyan]Enter output .nk file path[/cyan]", default=default_output ) # Resolution width = int(Prompt.ask("[cyan]Input resolution width[/cyan]", default="2048")) height = int(Prompt.ask("[cyan]Input resolution height[/cyan]", default="1556")) # Coordinate space - removed confusing option, using auto-detection console.print("\n[dim]Note: Coordinate space auto-detected as pixels (standard 3DE export)[/dim]") coord_space = "pixels" # Fill gaps fill_gaps = Confirm.ask("[cyan]Fill missing frames?[/cyan]", default=False) fill_strategy = "last" if fill_gaps: fill_strategy = Prompt.ask( "[cyan]Fill strategy[/cyan]", choices=["last", "linear"], default="last" ) # Enable all tracks enable_all = Confirm.ask("[cyan]Enable all tracks?[/cyan]", default=False) # Build config config = { 'input_width': width, 'input_height': height, 'input_space': coord_space, 'fill_gaps': fill_gaps, 'fill_strategy': fill_strategy, 'enable_all': enable_all, 'flip_y': False, 'name_prefix': '', 'nuke_version': '15.2 v3', 'format': '2K_Super_35(full-ap)' } # Process converter = TrackConverter(config) console.print("\n") if converter.parse_3de_file(Path(input_file)): console.print("[green]✓[/green] Successfully parsed 3DE file\n", style="bold green") converter.display_summary() console.print("\n") if converter.generate_nuke_file(Path(output_file)): console.print(f"\n[green]✓[/green] Successfully created: [bold]{output_file}[/bold]", style="bold green") # Final summary summary = Panel( f"[green]Conversion Complete![/green]\n\n" f"[cyan]Input:[/cyan] {input_file}\n" f"[cyan]Output:[/cyan] {output_file}\n" f"[cyan]Tracks:[/cyan] {len(converter.tracks)}\n" f"[cyan]Total Points:[/cyan] {sum(t.num_points for t in converter.tracks)}", title="✓ Success", border_style="green", box=box.DOUBLE ) console.print(summary) def cli_mode(args): """Run in CLI mode with arguments""" config = { 'input_width': args.width, 'input_height': args.height, 'input_space': args.input_space, 'fill_gaps': args.fill_gaps, 'fill_strategy': args.fill_strategy, 'enable_all': args.enable_all, 'flip_y': args.flip_y, 'name_prefix': args.name_prefix, 'nuke_version': args.nuke_version, 'format': args.format } if not args.quiet: console.print(Panel(BANNER, style="bold cyan", box=box.DOUBLE)) converter = TrackConverter(config) if converter.parse_3de_file(Path(args.input)): if not args.quiet: console.print("[green]✓[/green] Successfully parsed 3DE file\n", style="bold green") if args.verbose: converter.display_summary() if args.dry_run: console.print("[yellow]Dry run - no file written[/yellow]") return if converter.generate_nuke_file(Path(args.output)): if not args.quiet: console.print(f"\n[green]✓[/green] Successfully created: [bold]{args.output}[/bold]", style="bold green") else: sys.exit(1) else: sys.exit(1) def main(): parser = argparse.ArgumentParser( description='Convert 3DEqualizer 2D tracks to Nuke Tracker nodes', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('-i', '--input', help='Input 3DE track file') parser.add_argument('-o', '--output', help='Output Nuke .nk file') parser.add_argument('-w', '--width', type=int, default=2048, help='Input resolution width (default: 2048)') parser.add_argument('--height', type=int, default=1556, help='Input resolution height (default: 1556)') parser.add_argument('--input-space', choices=['pixels', 'normalized'], default='pixels', help='Input coordinate space (default: pixels)') parser.add_argument('--fill-gaps', action='store_true', help='Fill missing frames') parser.add_argument('--fill-strategy', choices=['last', 'linear'], default='last', help='Gap filling strategy (default: last)') parser.add_argument('--flip-y', action='store_true', help='Flip Y coordinates') parser.add_argument('--enable-all', action='store_true', help='Enable all tracks (default: only first)') parser.add_argument('--name-prefix', default='', help='Prefix for track names') parser.add_argument('--nuke-version', default='15.2 v3', help='Nuke version (default: 15.2 v3)') parser.add_argument('--format', default='2K_Super_35(full-ap)', help='Nuke format name') parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode') parser.add_argument('--dry-run', action='store_true', help='Parse only, don\'t write file') # If no arguments, run interactive mode if len(sys.argv) == 1: interactive_mode() else: args = parser.parse_args() if not args.input or not args.output: parser.error("--input and --output are required in CLI mode") cli_mode(args) if __name__ == '__main__': try: main() except KeyboardInterrupt: console.print("\n[yellow]Cancelled by user[/yellow]") sys.exit(0) except Exception as e: console.print(f"\n[red]Fatal error: {str(e)}[/red]", style="bold red") sys.exit(1)