22import os
33import re
44import glob
5+ import math
6+ from functools import reduce
57from datetime import datetime
68
79
8- def get_latest_exr_sequence (render_root ):
10+ def _find_images_dir (base ):
11+ """Return images dir under base, preferring images_dn over images."""
12+ for sub in ("images_dn" , "images" ):
13+ p = os .path .join (base , sub )
14+ if os .path .isdir (p ):
15+ return p
16+ return None
17+
18+
19+ def _gcd_list (values ):
20+ """Greatest common divisor for a list of positive integers."""
21+ if not values :
22+ return 1
23+ return reduce (math .gcd , values )
24+
25+
26+ def _scan_exr_sequence (images_dir ):
927 """
10- Scan render_root for MM-DD-YYYY_V_### folders and, inside the newest one,
11- discover all .exr files, extract their frame numbers & padding, and return:
12- (pattern, first_frame, last_frame)
13- where pattern is something like '/…/images/%04d.exr'
28+ Return (pattern, first_frame, last_frame, pad, cadence_step)
29+ cadence_step is the GCD of frame gaps (1 = full, 2 = on 2s, 4 = on 4s, etc.)
30+ """
31+ files = glob .glob (os .path .join (images_dir , "*.exr" ))
32+ if not files :
33+ return None
34+
35+ nums , pad = [], 0
36+ for f in files :
37+ fname = os .path .basename (f )
38+ m = re .match (r"^(.*?)(\d+)\.exr$" , fname )
39+ if not m :
40+ continue
41+ n = int (m .group (2 ))
42+ nums .append (n )
43+ pad = max (pad , len (m .group (2 )))
44+
45+ if not nums :
46+ return None
47+
48+ nums = sorted (set (nums ))
49+ first_frame = nums [0 ]
50+ last_frame = nums [- 1 ]
51+ diffs = [b - a for a , b in zip (nums , nums [1 :]) if b > a ]
52+ step = _gcd_list (diffs ) if diffs else 1
53+ if step <= 0 :
54+ step = 1
55+
56+ pattern = os .path .join (images_dir , "%%0%dd.exr" % pad )
57+ return pattern , first_frame , last_frame , pad , step
58+
59+
60+ def get_latest_exr_sequences (render_root ):
61+ """
62+ Discover EXR sequences in the newest MM-DD-YY_V_### directory.
63+
64+ - If subfolders exist under the newest date/version dir:
65+ scan each subfolder; in each, prefer images_dn/ over images/
66+ - If no subfolders:
67+ look directly under the newest date/version dir for images_dn/ or images/
68+
69+ Returns a list of dicts:
70+ [{
71+ 'label': <subfolder or 'root'>,
72+ 'pattern': '/…/%04d.exr',
73+ 'first': int,
74+ 'last': int,
75+ 'step': int # 1 (full), 2 (on 2s), 4 (on 4s), etc.
76+ }, ...]
1477 """
1578 if not os .path .isdir (render_root ):
1679 nuke .message (f"[Auto Read] Render folder does not exist:\n { render_root } " )
17- return None , None , None
80+ return []
1881
19- # find latest date/version subfolder
2082 folder_pattern = re .compile (r"^(\d{2}-\d{2}-\d{2})_V_(\d{3})$" )
2183 candidates = []
2284 for name in os .listdir (render_root ):
@@ -28,77 +90,169 @@ def get_latest_exr_sequence(render_root):
2890 candidates .append ((date_obj , version , path ))
2991 if not candidates :
3092 nuke .message ("[Auto Read] No valid render subfolders found." )
31- return None , None , None
93+ return []
3294
33- # pick newest, then check for images_dn/images
3495 _ , _ , newest = sorted (candidates , key = lambda x : (x [0 ], x [1 ]))[- 1 ]
35- for sub in ("images_dn" , "images" ):
36- subdir = os .path .join (newest , sub )
37- if os .path .isdir (subdir ):
38- newest = subdir
39- break
4096
41- # glob all .exr files
42- files = glob .glob (os .path .join (newest , "*.exr" ))
43- if not files :
44- nuke .message (f"[Auto Read] No .exr files found in:\n { newest } " )
45- return None , None , None
97+ subfolders = [d for d in os .listdir (newest )
98+ if os .path .isdir (os .path .join (newest , d ))]
4699
47- # extract frame numbers and detect padding
48- nums , pad = [], 0
49- for f in files :
50- fname = os .path .basename (f )
51- m = re .match (r"^(.*?)(\d+)\.exr$" , fname )
52- if not m :
53- continue
54- nums .append (int (m .group (2 )))
55- pad = max (pad , len (m .group (2 )))
100+ sequences = []
56101
57- if not nums :
58- nuke .message (f"[Auto Read] Couldn't parse frame numbers in:\n { newest } " )
59- return None , None , None
102+ if subfolders :
103+ for sub in sorted (subfolders ):
104+ base = os .path .join (newest , sub )
105+ images_dir = _find_images_dir (base )
106+ if not images_dir :
107+ continue
108+ seq = _scan_exr_sequence (images_dir )
109+ if not seq :
110+ continue
111+ pattern , first , last , _pad , step = seq
112+ sequences .append ({
113+ "label" : sub ,
114+ "pattern" : pattern ,
115+ "first" : first ,
116+ "last" : last ,
117+ "step" : step
118+ })
119+ if not sequences :
120+ nuke .message (f"[Auto Read] No .exr sequences found under any subfolder in:\n { newest } " )
121+ return []
122+ else :
123+ images_dir = _find_images_dir (newest )
124+ if not images_dir :
125+ nuke .message (f"[Auto Read] Neither images_dn nor images found in:\n { newest } " )
126+ return []
127+ seq = _scan_exr_sequence (images_dir )
128+ if not seq :
129+ nuke .message (f"[Auto Read] No .exr files found in:\n { images_dir } " )
130+ return []
131+ pattern , first , last , _pad , step = seq
132+ sequences .append ({
133+ "label" : "root" ,
134+ "pattern" : pattern ,
135+ "first" : first ,
136+ "last" : last ,
137+ "step" : step
138+ })
139+
140+ return sequences
60141
61- first_frame = min (nums )
62- last_frame = max (nums )
63- pattern = os .path .join (newest , "%%0%dd.exr" % pad )
64142
65- return pattern , first_frame , last_frame
143+ def _sanitize_for_nuke (name ):
144+ """Keep node names safe for Nuke."""
145+ safe = re .sub (r"[^0-9a-zA-Z_]" , "_" , name )
146+ if safe and safe [0 ].isdigit ():
147+ safe = "_" + safe
148+ return safe or "seq"
66149
67150
68- def make_read_node ( render_subdir = "render" , node_name = "EXR_read" ):
151+ def _nearest_hold_expr ( first , last , step ):
69152 """
70- Create a Read node pointing at the full sequence (from first_frame to last_frame).
153+ Expression that maps timeline frame -> nearest existing frame in a stepped sequence.
154+ Uses floor(x + 0.5) to emulate round() for stability.
155+ """
156+ if step <= 1 :
157+ # identity mapping (no missing frames cadence)
158+ return f"clamp(frame, { first } , { last } )"
159+ # nearest multiple of 'step' from 'first', then clamp to [first,last]
160+ return f"clamp({ first } +{ step } *floor((frame-{ first } )/{ step } +0.5), { first } , { last } )"
161+
162+
163+ def make_read_nodes (render_subdir = "render" , node_name_prefix = "EXR_read" ):
164+ """
165+ Create one Read node per discovered sequence inside the newest date/version folder.
166+
167+ - Nodes are named: <prefix>_<label> (e.g., EXR_read_beauty)
168+ - Project frame range is set to the union [min(first), max(last)] across all sequences.
169+ - For sequences detected as rendered on 2s/4s (or any N-s cadence), the Read node's
170+ 'frame' knob is set to hold the nearest available frame so playback never errors.
171+ - If ALL sequences share the same cadence of 2 or 4, project FPS is divided by that cadence.
71172 """
72173 script_path = nuke .root ()["name" ].value ()
73174 if not script_path or script_path == "Root" :
74175 nuke .message ("Open your shot before using Auto Read." )
75- return None
176+ return []
76177
77178 shot_dir = os .path .dirname (os .path .dirname (script_path ))
78179 render_dir = os .path .join (shot_dir , render_subdir )
79180
80- seq_pattern , first , last = get_latest_exr_sequence (render_dir )
81- if not seq_pattern :
82- return None
181+ sequences = get_latest_exr_sequences (render_dir )
182+ if not sequences :
183+ return []
184+
185+ # remove any existing nodes created by this tool (by prefix)
83186 for n in nuke .allNodes ("Read" ):
84- if n .name () == node_name :
85- nuke .delete (n )
86- read = nuke .nodes .Read (name = node_name , file = seq_pattern , on_error = "black" )
87- # set both the sequence's native range and the playback range
88- read ["origfirst" ].setValue (first )
89- read ["origlast" ].setValue (last )
90- read ["first" ].setValue (first )
91- read ["last" ].setValue (last )
187+ try :
188+ if n .name ().startswith (node_name_prefix ):
189+ nuke .delete (n )
190+ except Exception :
191+ pass
192+
193+ created = []
194+ global_first = min (s ["first" ] for s in sequences )
195+ global_last = max (s ["last" ] for s in sequences )
196+
197+ for s in sequences :
198+ label = _sanitize_for_nuke (s ["label" ])
199+ node_name = f"{ node_name_prefix } _{ label } " if label != "root" else node_name_prefix
200+ read = nuke .nodes .Read (name = node_name , file = s ["pattern" ], on_error = "black" )
201+ # native sequence range
202+ read ["origfirst" ].setValue (s ["first" ])
203+ read ["origlast" ].setValue (s ["last" ])
204+ read ["first" ].setValue (s ["first" ])
205+ read ["last" ].setValue (s ["last" ])
206+
207+ # Time-map to handle sparse cadence (2s/4s/etc.) by holding nearest available frame
208+ expr = _nearest_hold_expr (s ["first" ], s ["last" ], s ["step" ])
209+ try :
210+ read ["frame" ].setExpression (expr )
211+ except Exception :
212+ # Fallback: if 'frame' knob is unavailable for some reason, do nothing
213+ pass
214+
215+ # Optional label so it's obvious what's happening
216+ try :
217+ read ["label" ].setValue (f"{ s ['label' ]} step:{ s ['step' ]} " )
218+ except Exception :
219+ pass
220+
221+ created .append (read )
222+
223+ # Set the project frame range to cover all created sequences
224+ nuke .root ()["first_frame" ].setValue (global_first )
225+ nuke .root ()["last_frame" ].setValue (global_last )
92226
93- nuke .root ()["first_frame" ].setValue (first )
94- nuke .root ()["last_frame" ].setValue (last )
227+ # If ALL sequences share cadence 2 or 4, adjust project FPS accordingly
228+ steps = set (s ["step" ] for s in sequences )
229+ if len (steps ) == 1 :
230+ only_step = steps .pop ()
231+ if only_step in (2 , 4 ):
232+ try :
233+ current_fps = float (nuke .root ()["fps" ].value ())
234+ new_fps = current_fps / float (only_step )
235+ nuke .root ()["fps" ].setValue (new_fps )
236+ nuke .tprint (f"[Auto Read] Detected cadence { only_step } s; FPS set to { new_fps :.3f} " )
237+ except Exception as e :
238+ nuke .tprint (f"[Auto Read] Could not adjust FPS: { e } " )
95239
96- return read
240+ return created
97241
98242
99243def auto_read_latest_fx_exr ():
100- read_node = make_read_node ("FX/render" , node_name = "Bobo_FX_read" )
101- if not read_node :
244+ nodes = make_read_nodes ("fx/render" , node_name_prefix = "Bobo_FX_read" )
245+ if not nodes :
246+ return
247+ try :
248+ viewer = nuke .activeViewer ().node ()
249+ nuke .zoom (1 , [viewer ["xpos" ].value (), viewer ["ypos" ].value ()])
250+ except Exception as e :
251+ nuke .tprint (f"[Auto Read] Viewer zoom error: { e } " )
252+
253+ def auto_read_latest_cfx_exr ():
254+ nodes = make_read_nodes ("cfx/render" , node_name_prefix = "Bobo_CFX_read" )
255+ if not nodes :
102256 return
103257 try :
104258 viewer = nuke .activeViewer ().node ()
@@ -109,10 +263,10 @@ def auto_read_latest_fx_exr():
109263
110264def auto_read_latest_exr ():
111265 """
112- Callback: build the Read node and zoom the Viewer.
266+ Callback: build the Read nodes and zoom the Viewer.
113267 """
114- read_node = make_read_node ()
115- if not read_node :
268+ nodes = make_read_nodes ()
269+ if not nodes :
116270 return
117271 try :
118272 viewer = nuke .activeViewer ().node ()
@@ -137,4 +291,4 @@ def main():
137291 main ()
138292 nuke .tprint ("[Auto Read] Module loaded and command registered." )
139293except Exception as e :
140- nuke .tprint (f"[Auto Read] Failed to initialize: { e } " )
294+ nuke .tprint (f"[Auto Read] Failed to initialize: { e } " )
0 commit comments