- Complete rewrite with beautiful Rich TUI interface - Interactive and CLI modes for flexibility - Robust error handling with clear, helpful messages - Gap filling with linear interpolation support - Coordinate system transforms (pixels/normalized) - Auto-generated output filenames from input - Configurable resolution and Nuke versions - Batch processing support via CLI - Comprehensive documentation in Scripts/README_CONVERTER.md - Updated main README.md with Scripts section
549 lines
22 KiB
Python
549 lines
22 KiB
Python
#!/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)
|