+621
-100
@@ -43,13 +43,17 @@ BASE_URL = "https://review.twotalesvfx.com"
|
|||||||
PROJECT_CODE = "UNG_S1"
|
PROJECT_CODE = "UNG_S1"
|
||||||
|
|
||||||
PROJECT_ROOT = "X:/shared_projects_2026/UNGO_VFX"
|
PROJECT_ROOT = "X:/shared_projects_2026/UNGO_VFX"
|
||||||
FOOTAGE_ROOT = PROJECT_ROOT + "/production/plates"
|
FOOTAGE_ROOT = "V:/_FOOTAGE/UNG"
|
||||||
EXPORT_ROOT = PROJECT_ROOT + "/production/renders"
|
EXPORT_ROOT = "V:/_EXPORTS/UNG"
|
||||||
PICLOCK_ROOT = PROJECT_ROOT + "/production/piclocks"
|
RENDER_ROOT = PROJECT_ROOT + "/production/renders"
|
||||||
|
PICLOCK_ROOT = "V:/_FOOTAGE/UNG/_PICLOCKS"
|
||||||
NK_ROOT = PROJECT_ROOT + "/production/nuke"
|
NK_ROOT = PROJECT_ROOT + "/production/nuke"
|
||||||
|
|
||||||
OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio"
|
OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio"
|
||||||
|
|
||||||
|
TEMPLATE_SCRIPT = PROJECT_ROOT + "/production/working_files/ung_template_script.nkind"
|
||||||
|
SHOW_NAME = "UNGOVERNABLE"
|
||||||
|
|
||||||
# ── API client ────────────────────────────────────────────────────────────────
|
# ── API client ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -188,22 +192,60 @@ def _format_exists(name: str) -> bool:
|
|||||||
def _find_plates(shot_code: str) -> list[str]:
|
def _find_plates(shot_code: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*.
|
Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*.
|
||||||
Returns a list of first-frame file paths.
|
|
||||||
|
Per project spec, searches FOOTAGE_ROOT/{episodeCode}/ for subfolders
|
||||||
|
named exactly {shotCode} or starting with {shotCode}_ (e.g. {shotCode}_A).
|
||||||
|
Falls back to a flat search directly under FOOTAGE_ROOT if no episode
|
||||||
|
subfolder exists. Each candidate folder is also scanned one level deep
|
||||||
|
to handle version subdirectories (e.g. .../shotCode_A/v001/*.exr).
|
||||||
|
|
||||||
|
Returns a list of first-frame file paths (one per EXR sequence found).
|
||||||
"""
|
"""
|
||||||
episode = shot_code[:7] # e.g. UNG_106
|
# Robustly extract episode prefix: UNG_106 from UNG_106_010_020
|
||||||
shot_dir = os.path.join(FOOTAGE_ROOT, episode, shot_code)
|
parts = shot_code.split("_")
|
||||||
results = []
|
ep_code = "_".join(parts[:2]) if len(parts) >= 2 else shot_code
|
||||||
|
|
||||||
if not os.path.isdir(shot_dir):
|
# Ordered list of roots to search — episode subfolder first, then flat
|
||||||
# Fall back: look for any subfolder under the episode root
|
search_roots = [
|
||||||
ep_dir = os.path.join(FOOTAGE_ROOT, episode)
|
os.path.join(FOOTAGE_ROOT, ep_code),
|
||||||
if os.path.isdir(ep_dir):
|
FOOTAGE_ROOT,
|
||||||
for sub in sorted(os.listdir(ep_dir)):
|
]
|
||||||
if sub.startswith(shot_code):
|
|
||||||
results.extend(_scan_for_exr(os.path.join(ep_dir, sub)))
|
results: list[str] = []
|
||||||
return results
|
|
||||||
|
for root in search_roots:
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries = sorted(os.listdir(root))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
# Match exact shot-code folder OR any {shotCode}_* variant
|
||||||
|
if entry != shot_code and not entry.startswith(shot_code + "_"):
|
||||||
|
continue
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
exrs = _scan_for_exr(entry_path)
|
||||||
|
if exrs:
|
||||||
|
results.extend(exrs)
|
||||||
|
else:
|
||||||
|
# Try one level deeper (e.g. shotCode_A/v001/*.exr)
|
||||||
|
try:
|
||||||
|
sub_entries = sorted(os.listdir(entry_path))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for sub in sub_entries:
|
||||||
|
sub_path = os.path.join(entry_path, sub)
|
||||||
|
if os.path.isdir(sub_path):
|
||||||
|
results.extend(_scan_for_exr(sub_path))
|
||||||
|
|
||||||
|
if results:
|
||||||
|
break # found at this root level — skip the flat fallback
|
||||||
|
|
||||||
results.extend(_scan_for_exr(shot_dir))
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -230,15 +272,28 @@ def _nuke_sequence_path(first_frame_path: str) -> str:
|
|||||||
first_frame_path, flags=re.IGNORECASE)
|
first_frame_path, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
|
def _safe_set_knob(node: "nuke.Node", knob_name: str, value) -> bool:
|
||||||
"""
|
"""Set *knob_name* on *node* if the knob exists. Returns True on success."""
|
||||||
Build a complete Nuke shot script for *shot_code*.
|
knob = node.knob(knob_name)
|
||||||
|
if knob is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
knob.setValue(value)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
- Fetches metadata from VFXReview
|
|
||||||
- Locates plates on disk
|
def build_shot(shot_code: str, api: VFXReviewAPI, shot_type: str = "2d comp") -> bool:
|
||||||
- Creates Read → OCIOColorSpace nodes
|
"""
|
||||||
- Creates Write_EXR and Write_Review nodes
|
Build a Nuke shot script for *shot_code* from the facility template.
|
||||||
- Saves the script
|
|
||||||
|
- Opens the facility template script
|
||||||
|
- Fetches shot metadata from VFXReview
|
||||||
|
- Sets Read1 to the first found plate
|
||||||
|
- Configures Netflix_MEI_Overlay and NETFLIX_TEMPLATE_SLATE
|
||||||
|
- Updates Write_EXR / Write_Review output paths
|
||||||
|
- Saves the script to NK_ROOT
|
||||||
|
|
||||||
Returns True on success.
|
Returns True on success.
|
||||||
"""
|
"""
|
||||||
@@ -252,20 +307,27 @@ def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
|
|||||||
nuke.message(f"Shot not found in VFXReview:\n{shot_code}")
|
nuke.message(f"Shot not found in VFXReview:\n{shot_code}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
episode = shot.get("episode", "")
|
episode = shot.get("episode", "")
|
||||||
|
scene = shot.get("scene", "")
|
||||||
frame_start = int(shot.get("frameStart") or 1001)
|
frame_start = int(shot.get("frameStart") or 1001)
|
||||||
frame_end = int(shot.get("frameEnd") or 1100)
|
frame_end = int(shot.get("frameEnd") or 1100)
|
||||||
fps = float(shot.get("fps") or 24)
|
fps = float(shot.get("fps") or 24)
|
||||||
description = shot.get("description", "")
|
description = shot.get("description", "")
|
||||||
exr_output = shot.get("exrOutput") or f"{shot_code}_comp_TT_v001"
|
exr_output = shot.get("exrOutput") or f"{shot_code}_comp_TT_v001"
|
||||||
|
|
||||||
# ── Check / create save path ──────────────────────────────────────────────
|
ep_code = f"UNG_{str(episode).zfill(3)}" if str(episode).isdigit() else str(episode)
|
||||||
ep_code = f"UNG_{str(episode).zfill(3)}" if str(episode).isdigit() else str(episode)
|
shot_name = f"{shot_code}_cmp_TT_v001"
|
||||||
script_dir = os.path.join(NK_ROOT, ep_code, shot_code)
|
script_dir = os.path.join(NK_ROOT, ep_code, shot_code)
|
||||||
script_path = os.path.join(script_dir, f"{shot_code}_comp_v001.nk")
|
script_path = os.path.join(script_dir, f"{shot_code}_comp_v001.nk")
|
||||||
|
|
||||||
os.makedirs(script_dir, exist_ok=True)
|
os.makedirs(script_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Open template ─────────────────────────────────────────────────────────
|
||||||
|
if not os.path.isfile(TEMPLATE_SCRIPT):
|
||||||
|
nuke.message(f"Template script not found:\n{TEMPLATE_SCRIPT}")
|
||||||
|
return False
|
||||||
|
nuke.scriptOpen(TEMPLATE_SCRIPT)
|
||||||
|
|
||||||
# ── Project settings ──────────────────────────────────────────────────────
|
# ── Project settings ──────────────────────────────────────────────────────
|
||||||
root = nuke.root()
|
root = nuke.root()
|
||||||
root["fps"].setValue(fps)
|
root["fps"].setValue(fps)
|
||||||
@@ -277,88 +339,234 @@ def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
|
|||||||
root["OCIO_config"].setValue("custom")
|
root["OCIO_config"].setValue("custom")
|
||||||
root["customOCIOConfigPath"].setValue(OCIO_CONFIG)
|
root["customOCIOConfigPath"].setValue(OCIO_CONFIG)
|
||||||
|
|
||||||
# ── Locate plates ─────────────────────────────────────────────────────────
|
# ── Locate plates → configure Read1 ──────────────────────────────────────
|
||||||
plate_paths = _find_plates(shot_code)
|
plate_paths = _find_plates(shot_code)
|
||||||
|
plate_warning = ""
|
||||||
|
read1 = nuke.toNode("Read1")
|
||||||
|
|
||||||
read_nodes: list[nuke.Node] = []
|
if plate_paths:
|
||||||
x_cursor = 0
|
seq_path = _nuke_sequence_path(plate_paths[0]).replace("\\", "/")
|
||||||
|
if read1 is None:
|
||||||
|
read1 = nuke.createNode("Read", inpanel=False)
|
||||||
|
read1.setName("Read1")
|
||||||
|
read1["file"].setValue(seq_path)
|
||||||
|
read1["first"].setValue(frame_start)
|
||||||
|
read1["last"].setValue(frame_end)
|
||||||
|
read1["colorspace"].setValue("raw")
|
||||||
|
else:
|
||||||
|
plate_warning = f"No plates found under {FOOTAGE_ROOT} — set Read1 manually"
|
||||||
|
if read1:
|
||||||
|
read1["file"].setValue("")
|
||||||
|
|
||||||
for plate_path in plate_paths:
|
# ── Configure Netflix_MEI_Overlay ─────────────────────────────────────────
|
||||||
seq_path = _nuke_sequence_path(plate_path)
|
mei = nuke.toNode("Netflix_MEI_Overlay")
|
||||||
read = nuke.createNode("Read", inpanel=False)
|
if mei:
|
||||||
read["file"].setValue(seq_path)
|
_safe_set_knob(mei, "bottomleft", shot_name)
|
||||||
read["first"].setValue(frame_start)
|
|
||||||
read["last"].setValue(frame_end)
|
|
||||||
read["colorspace"].setValue("ACES2065-1")
|
|
||||||
read.setName(_safe(f"Read_{os.path.basename(os.path.dirname(plate_path))}"))
|
|
||||||
read.setXYpos(x_cursor, 0)
|
|
||||||
|
|
||||||
color = nuke.createNode("OCIOColorSpace", inpanel=False)
|
# ── Configure NETFLIX_TEMPLATE_SLATE ─────────────────────────────────────
|
||||||
color["in_colorspace"].setValue("ACES2065-1")
|
slate = nuke.toNode("NETFLIX_TEMPLATE_SLATE")
|
||||||
color["out_colorspace"].setValue("ACEScg")
|
if slate:
|
||||||
color.setInput(0, read)
|
_safe_set_knob(slate, "f_version_name", shot_name)
|
||||||
color.setXYpos(x_cursor, 100)
|
_safe_set_knob(slate, "f_submitting_for", "APPROVAL")
|
||||||
color.setName(_safe(f"CS_{read.name()}"))
|
_safe_set_knob(slate, "f_shot_name", shot_code)
|
||||||
|
_safe_set_knob(slate, "f_shot_type", shot_type)
|
||||||
|
_safe_set_knob(slate, "f_show", SHOW_NAME)
|
||||||
|
_safe_set_knob(slate, "f_vendor", "TWO TALES ANIMATION (PTY) LTD")
|
||||||
|
_safe_set_knob(slate, "f_media_color", "Rec.709 w/SHOW LUT")
|
||||||
|
_safe_set_knob(slate, "f_shot_description", description)
|
||||||
|
_safe_set_knob(slate, "f_episode", ep_code)
|
||||||
|
_safe_set_knob(slate, "f_scene", str(scene))
|
||||||
|
|
||||||
read_nodes.append(read)
|
# ── Update Write paths ────────────────────────────────────────────────────
|
||||||
x_cursor += 200
|
|
||||||
|
|
||||||
# ── Merge / dot into comp area ────────────────────────────────────────────
|
|
||||||
# If we have plates, connect the first into a "COMP" dot
|
|
||||||
comp_dot = nuke.createNode("Dot", inpanel=False)
|
|
||||||
comp_dot.setXYpos(0, 250)
|
|
||||||
comp_dot.setName("Dot_COMP")
|
|
||||||
if read_nodes:
|
|
||||||
# Wire the OCIOColorSpace node below the first read into the dot
|
|
||||||
cs_nodes = [n for n in nuke.allNodes("OCIOColorSpace")]
|
|
||||||
if cs_nodes:
|
|
||||||
comp_dot.setInput(0, cs_nodes[0])
|
|
||||||
|
|
||||||
# ── Write EXR ─────────────────────────────────────────────────────────────
|
|
||||||
exr_dir = os.path.join(EXPORT_ROOT, shot_code)
|
exr_dir = os.path.join(EXPORT_ROOT, shot_code)
|
||||||
os.makedirs(exr_dir, exist_ok=True)
|
os.makedirs(exr_dir, exist_ok=True)
|
||||||
exr_path = os.path.join(exr_dir, f"{exr_output}.####.exr").replace("\\", "/")
|
exr_path = os.path.join(exr_dir, f"{exr_output}.####.exr").replace("\\", "/")
|
||||||
|
mov_path = os.path.join(EXPORT_ROOT, f"{shot_name}.mov").replace("\\", "/")
|
||||||
|
|
||||||
write_exr = nuke.createNode("Write", inpanel=False)
|
write_exr = nuke.toNode("Write_EXR")
|
||||||
write_exr.setName("Write_EXR")
|
if write_exr:
|
||||||
write_exr["file"].setValue(exr_path)
|
write_exr["file"].setValue(exr_path)
|
||||||
write_exr["file_type"].setValue("exr")
|
|
||||||
write_exr["datatype"].setValue("16 bit half")
|
|
||||||
write_exr["compression"].setValue("ZIP (1 scanline)")
|
|
||||||
write_exr["colorspace"].setValue("ACEScg")
|
|
||||||
write_exr["create_directories"].setValue(True)
|
|
||||||
write_exr.setInput(0, comp_dot)
|
|
||||||
write_exr.setXYpos(0, 400)
|
|
||||||
|
|
||||||
# ── Write Review MOV ──────────────────────────────────────────────────────
|
write_mov = nuke.toNode("Write_Review")
|
||||||
mov_path = os.path.join(EXPORT_ROOT, f"{shot_code}_cmp_TT_v001.mov").replace("\\", "/")
|
if write_mov:
|
||||||
|
write_mov["file"].setValue(mov_path)
|
||||||
write_mov = nuke.createNode("Write", inpanel=False)
|
|
||||||
write_mov.setName("Write_Review")
|
|
||||||
write_mov["file"].setValue(mov_path)
|
|
||||||
write_mov["file_type"].setValue("mov")
|
|
||||||
write_mov["colorspace"].setValue("Output - Rec.709")
|
|
||||||
write_mov["create_directories"].setValue(True)
|
|
||||||
write_mov.setInput(0, comp_dot)
|
|
||||||
write_mov.setXYpos(200, 400)
|
|
||||||
|
|
||||||
# ── Backdrops ─────────────────────────────────────────────────────────────
|
|
||||||
if read_nodes:
|
|
||||||
all_input_nodes = nuke.allNodes("Read") + nuke.allNodes("OCIOColorSpace")
|
|
||||||
_create_backdrop("PLATES", all_input_nodes, color=0x1F3A4AFF)
|
|
||||||
|
|
||||||
_create_backdrop("COMP", [comp_dot], color=0x2A3A2AFF)
|
|
||||||
_create_backdrop("WRITE", [write_exr, write_mov], color=0x3A2A2AFF)
|
|
||||||
|
|
||||||
# ── Viewer ────────────────────────────────────────────────────────────────
|
|
||||||
viewer = nuke.createNode("Viewer", inpanel=False)
|
|
||||||
viewer.setInput(0, comp_dot)
|
|
||||||
viewer.setXYpos(400, 250)
|
|
||||||
|
|
||||||
# ── Save script ───────────────────────────────────────────────────────────
|
# ── Save script ───────────────────────────────────────────────────────────
|
||||||
nuke.scriptSaveAs(script_path, overwrite=False)
|
nuke.scriptSaveAs(script_path, overwrite=False)
|
||||||
|
|
||||||
return True
|
return True, plate_warning
|
||||||
|
|
||||||
|
|
||||||
|
def import_renders(shot_code: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Scan RENDER_ROOT/{shotCode}/ for EXR sequences and create Read → Shuffle
|
||||||
|
pairs for each one found.
|
||||||
|
|
||||||
|
Checks subdirectories first (individual render passes such as beauty,
|
||||||
|
matte, fx …), then falls back to sequences directly in the shot folder.
|
||||||
|
|
||||||
|
Returns (ok, message).
|
||||||
|
"""
|
||||||
|
if not _IN_NUKE:
|
||||||
|
print(f"[VFXReview] Would import renders for: {shot_code}")
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
render_dir = os.path.join(RENDER_ROOT, shot_code)
|
||||||
|
if not os.path.isdir(render_dir):
|
||||||
|
return False, f"Render folder not found: {render_dir}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = sorted(os.listdir(render_dir))
|
||||||
|
except OSError as exc:
|
||||||
|
return False, f"Cannot read render folder: {exc}"
|
||||||
|
|
||||||
|
# Build list of (nuke_sequence_path, layer_label)
|
||||||
|
sequences: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
subdirs = [e for e in entries if os.path.isdir(os.path.join(render_dir, e))]
|
||||||
|
if subdirs:
|
||||||
|
for sub in subdirs:
|
||||||
|
for exr in _scan_for_exr(os.path.join(render_dir, sub)):
|
||||||
|
seq = _nuke_sequence_path(exr).replace("\\", "/")
|
||||||
|
sequences.append((seq, sub))
|
||||||
|
else:
|
||||||
|
# No pass subfolders — grab sequences directly in the shot folder
|
||||||
|
for exr in _scan_for_exr(render_dir):
|
||||||
|
label = re.sub(r"[\._]\d+\.exr$", "", os.path.basename(exr), flags=re.IGNORECASE)
|
||||||
|
seq = _nuke_sequence_path(exr).replace("\\", "/")
|
||||||
|
sequences.append((seq, label))
|
||||||
|
|
||||||
|
if not sequences:
|
||||||
|
return False, f"No EXR sequences found in: {render_dir}"
|
||||||
|
|
||||||
|
x_cursor = 0
|
||||||
|
for seq_path, layer_name in sequences:
|
||||||
|
read = nuke.createNode("Read", inpanel=False)
|
||||||
|
read["file"].setValue(seq_path)
|
||||||
|
read.setName(_safe(f"Read_{layer_name}"))
|
||||||
|
read.setXYpos(x_cursor, 0)
|
||||||
|
|
||||||
|
shuffle = nuke.createNode("Shuffle", inpanel=False)
|
||||||
|
shuffle.setInput(0, read)
|
||||||
|
shuffle.setName(_safe(f"Shuffle_{layer_name}"))
|
||||||
|
shuffle["label"].setValue(layer_name)
|
||||||
|
shuffle.setXYpos(x_cursor, 150)
|
||||||
|
|
||||||
|
x_cursor += 220
|
||||||
|
|
||||||
|
return True, f"Imported {len(sequences)} render pass(es) for {shot_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tc_to_seconds(tc: str, fps: float) -> float:
|
||||||
|
"""Convert HH:MM:SS:FF (or drop-frame HH:MM:SS;FF) timecode to seconds."""
|
||||||
|
parts = re.split(r"[:;]", str(tc).strip())
|
||||||
|
if len(parts) < 4:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
hh = int(parts[0])
|
||||||
|
mm = int(parts[1])
|
||||||
|
ss = int(parts[2])
|
||||||
|
ff = int(parts[3])
|
||||||
|
return hh * 3600.0 + mm * 60.0 + ss + ff / (fps or 24.0)
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _find_piclock(ep_code: str) -> str | None:
|
||||||
|
"""Return the path of the first file matching {ep_code}_* in PICLOCK_ROOT."""
|
||||||
|
if not os.path.isdir(PICLOCK_ROOT):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for entry in sorted(os.listdir(PICLOCK_ROOT)):
|
||||||
|
if entry.startswith(ep_code + "_"):
|
||||||
|
full = os.path.join(PICLOCK_ROOT, entry)
|
||||||
|
if os.path.isfile(full):
|
||||||
|
return full
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pull_piclock(shot_code: str, api: VFXReviewAPI) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Find the offline picture-lock for *shot_code*, create a Read node trimmed
|
||||||
|
to the shot’s sequence timecode range (with ±8-frame handles), and attach
|
||||||
|
a Switch node that enables the piclock between frames 1009 and
|
||||||
|
(last_frame - 9) — i.e. only inside the main content range.
|
||||||
|
|
||||||
|
Returns (ok, message).
|
||||||
|
"""
|
||||||
|
if not _IN_NUKE:
|
||||||
|
print(f"[VFXReview] Would pull piclock for: {shot_code}")
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
# ── Fetch metadata ────────────────────────────────────────────────────────
|
||||||
|
shot = api.lookup_shot(shot_code)
|
||||||
|
if not shot:
|
||||||
|
return False, f"Shot not found in VFXReview: {shot_code}"
|
||||||
|
|
||||||
|
seq_tc_start = shot.get("seqTimecodeStart", "")
|
||||||
|
seq_tc_end = shot.get("seqTimecodeEnd", "")
|
||||||
|
if not seq_tc_start or not seq_tc_end:
|
||||||
|
return False, "No sequence timecodes set — import from EDL/CSV first"
|
||||||
|
|
||||||
|
fps = float(shot.get("fps") or 24)
|
||||||
|
frame_start = int(shot.get("frameStart") or 1001)
|
||||||
|
|
||||||
|
# ── Locate piclock file ───────────────────────────────────────────────────
|
||||||
|
ep_parts = shot_code.split("_")
|
||||||
|
ep_code = "_".join(ep_parts[:2]) # e.g. UNG_106
|
||||||
|
piclock_path = _find_piclock(ep_code)
|
||||||
|
if not piclock_path:
|
||||||
|
return False, f"No piclock found for {ep_code} in {PICLOCK_ROOT}"
|
||||||
|
|
||||||
|
# ── Timecode → frame offset calculation ──────────────────────────────────
|
||||||
|
# Show convention: piclock starts at 01:00:00:00 (3590 s per AE connector)
|
||||||
|
piclock_start_secs = 3590.0
|
||||||
|
handles = 8
|
||||||
|
|
||||||
|
tc_in_secs = _tc_to_seconds(seq_tc_start, fps)
|
||||||
|
tc_out_secs = _tc_to_seconds(seq_tc_end, fps)
|
||||||
|
|
||||||
|
in_frames = int(round((tc_in_secs - piclock_start_secs) * fps)) - handles
|
||||||
|
out_frames = int(round((tc_out_secs - piclock_start_secs) * fps)) + handles
|
||||||
|
if in_frames < 0:
|
||||||
|
in_frames = 0
|
||||||
|
|
||||||
|
# With frame_mode="offset" and frame=N, comp frame F reads footage frame F+N.
|
||||||
|
# We want comp frame_start → footage frame in_frames, so N = in_frames - frame_start.
|
||||||
|
offset = in_frames - frame_start + 1
|
||||||
|
duration = out_frames - in_frames
|
||||||
|
|
||||||
|
# ── Create Read node ──────────────────────────────────────────────────────────
|
||||||
|
read = nuke.createNode("Read", inpanel=False)
|
||||||
|
read["file"].setValue(piclock_path.replace("\\", "/"))
|
||||||
|
read["frame_mode"].setValue("offset")
|
||||||
|
read["frame"].setValue(str(offset))
|
||||||
|
# read["first"].setValue(frame_start)
|
||||||
|
read["last"].setValue(100000)
|
||||||
|
read["colorspace"].setValue("sRGB Encoded Rec.709 (sRGB)")
|
||||||
|
read.setName("Read_Piclock")
|
||||||
|
read.setXYpos(1000, 0)
|
||||||
|
|
||||||
|
# ── Create Switch node ─────────────────────────────────────────────────────────
|
||||||
|
# Input 0 = off (unconnected / black), Input 1 = piclock.
|
||||||
|
# which = 1 only inside content range; 0 in the handle zones.
|
||||||
|
switch = nuke.createNode("Switch", inpanel=False)
|
||||||
|
switch.setInput(1, read)
|
||||||
|
switch.setName("Switch_Piclock")
|
||||||
|
switch["which"].setExpression(
|
||||||
|
"(frame > 1008 && frame < root.last_frame - 8) ? 1 : 0"
|
||||||
|
)
|
||||||
|
switch.setXYpos(1000, 180)
|
||||||
|
|
||||||
|
# ── REFERENCE backdrop ──────────────────────────────────────────────────────
|
||||||
|
_create_backdrop("REFERENCE", [read, switch], color=0x2A1F4AFF)
|
||||||
|
|
||||||
|
return True, (
|
||||||
|
f"Piclock: {os.path.basename(piclock_path)} "
|
||||||
|
f"{seq_tc_start} \u2192 {seq_tc_end} (+{handles}fr handles)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Panel ─────────────────────────────────────────────────────────────────────
|
# ── Panel ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -444,6 +652,61 @@ class ConnectionWidget(QtWidgets.QGroupBox):
|
|||||||
self._set_state(ok, msg)
|
self._set_state(ok, msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _VersionThumbFetcher(QtCore.QThread):
|
||||||
|
"""
|
||||||
|
Calls lookup_shot() in a background thread and emits the best available
|
||||||
|
thumbnail URL: shot-level thumbnailUrl first, then the latest version's
|
||||||
|
thumbnailUrl, then empty string if neither is set.
|
||||||
|
"""
|
||||||
|
found = Signal(int, str) # (request_id, url_or_empty)
|
||||||
|
|
||||||
|
def __init__(self, shot_code: str, api: "VFXReviewAPI", request_id: int, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._shot_code = shot_code
|
||||||
|
self._api = api
|
||||||
|
self._request_id = request_id
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
shot = self._api.lookup_shot(self._shot_code)
|
||||||
|
url = ""
|
||||||
|
if shot:
|
||||||
|
url = shot.get("thumbnailUrl") or ""
|
||||||
|
if not url:
|
||||||
|
for ver in shot.get("versions") or []:
|
||||||
|
url = ver.get("thumbnailUrl") or ""
|
||||||
|
if url:
|
||||||
|
break
|
||||||
|
self.found.emit(self._request_id, url)
|
||||||
|
except Exception:
|
||||||
|
self.found.emit(self._request_id, "")
|
||||||
|
|
||||||
|
|
||||||
|
class _ThumbnailLoader(QtCore.QThread):
|
||||||
|
"""Fetches a thumbnail URL in a background thread and emits (request_id, bytes)."""
|
||||||
|
loaded = Signal(int, bytes)
|
||||||
|
|
||||||
|
def __init__(self, url: str, request_id: int, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._url = url
|
||||||
|
self._request_id = request_id
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
full_url = self._url if self._url.startswith("http") else BASE_URL + self._url
|
||||||
|
req = urllib.request.Request(
|
||||||
|
full_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Accept": "image/*",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
self.loaded.emit(self._request_id, resp.read())
|
||||||
|
except Exception:
|
||||||
|
self.loaded.emit(self._request_id, b"")
|
||||||
|
|
||||||
|
|
||||||
class ShotBuilderWidget(QtWidgets.QGroupBox):
|
class ShotBuilderWidget(QtWidgets.QGroupBox):
|
||||||
"""Episode → Shot dropdowns + Build Shot button."""
|
"""Episode → Shot dropdowns + Build Shot button."""
|
||||||
|
|
||||||
@@ -471,6 +734,18 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
row1.addWidget(self._shot_combo, 1)
|
row1.addWidget(self._shot_combo, 1)
|
||||||
layout.addLayout(row1)
|
layout.addLayout(row1)
|
||||||
|
|
||||||
|
# ── Row 1b: shot type ─────────────────────────────────────────────────
|
||||||
|
row1b = QtWidgets.QHBoxLayout()
|
||||||
|
shot_type_lbl = QtWidgets.QLabel("Shot Type:")
|
||||||
|
shot_type_lbl.setFixedWidth(65)
|
||||||
|
self._shot_type_combo = QtWidgets.QComboBox()
|
||||||
|
for _st in ("Screen Fill", "Clean up", "2d comp", "3d", "comp"):
|
||||||
|
self._shot_type_combo.addItem(_st)
|
||||||
|
self._shot_type_combo.setCurrentIndex(2) # default: 2d comp
|
||||||
|
row1b.addWidget(shot_type_lbl)
|
||||||
|
row1b.addWidget(self._shot_type_combo, 1)
|
||||||
|
layout.addLayout(row1b)
|
||||||
|
|
||||||
# ── Row 2: action buttons ─────────────────────────────────────────────
|
# ── Row 2: action buttons ─────────────────────────────────────────────
|
||||||
row2 = QtWidgets.QHBoxLayout()
|
row2 = QtWidgets.QHBoxLayout()
|
||||||
self._refresh_btn = QtWidgets.QPushButton("Refresh")
|
self._refresh_btn = QtWidgets.QPushButton("Refresh")
|
||||||
@@ -480,17 +755,38 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
row2.addWidget(self._build_btn)
|
row2.addWidget(self._build_btn)
|
||||||
layout.addLayout(row2)
|
layout.addLayout(row2)
|
||||||
|
|
||||||
|
# ── Row 2b: import renders + piclock buttons ──────────────────────────
|
||||||
|
self._import_btn = QtWidgets.QPushButton("Import Renders")
|
||||||
|
self._piclock_btn = QtWidgets.QPushButton("Pull Picture Lock")
|
||||||
|
layout.addWidget(self._import_btn)
|
||||||
|
layout.addWidget(self._piclock_btn)
|
||||||
|
|
||||||
# ── Row 3: shot info label ────────────────────────────────────────────
|
# ── Row 3: shot info label ────────────────────────────────────────────
|
||||||
self._info_label = QtWidgets.QLabel("")
|
self._info_label = QtWidgets.QLabel("")
|
||||||
self._info_label.setWordWrap(True)
|
self._info_label.setWordWrap(True)
|
||||||
self._info_label.setStyleSheet("color: #AAAAAA; font-size: 11px;")
|
self._info_label.setStyleSheet("color: #AAAAAA; font-size: 11px;")
|
||||||
layout.addWidget(self._info_label)
|
layout.addWidget(self._info_label)
|
||||||
|
# ── Thumbnail ──────────────────────────────────────────────────────────────
|
||||||
|
self._thumb_label = QtWidgets.QLabel()
|
||||||
|
self._thumb_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self._thumb_label.setMinimumHeight(80)
|
||||||
|
self._thumb_label.setMaximumHeight(120)
|
||||||
|
self._thumb_label.setStyleSheet(
|
||||||
|
"background: #181818; border: 1px solid #333; color: #555; font-size: 11px;"
|
||||||
|
)
|
||||||
|
self._thumb_label.setVisible(False)
|
||||||
|
layout.addWidget(self._thumb_label)
|
||||||
|
self._thumb_request_id: int = 0
|
||||||
|
self._version_fetch_id: int = 0
|
||||||
|
# Holds ALL active QThreads so Python's GC never destroys a running thread
|
||||||
|
self._threads: set = set()
|
||||||
# ── Wire signals ──────────────────────────────────────────────────────
|
# ── Wire signals ──────────────────────────────────────────────────────
|
||||||
self._episode_combo.currentIndexChanged.connect(self._on_episode_changed)
|
self._episode_combo.currentIndexChanged.connect(self._on_episode_changed)
|
||||||
self._shot_combo.currentIndexChanged.connect(self._on_shot_changed)
|
self._shot_combo.currentIndexChanged.connect(self._on_shot_changed)
|
||||||
self._refresh_btn.clicked.connect(self._load_episodes)
|
self._refresh_btn.clicked.connect(self._load_episodes)
|
||||||
self._build_btn.clicked.connect(self._build)
|
self._build_btn.clicked.connect(self._build)
|
||||||
|
self._import_btn.clicked.connect(self._import_renders)
|
||||||
|
self._piclock_btn.clicked.connect(self._pull_piclock)
|
||||||
|
|
||||||
# ── Public ────────────────────────────────────────────────────────────────
|
# ── Public ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -557,6 +853,7 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
shot = self._shot_combo.currentData()
|
shot = self._shot_combo.currentData()
|
||||||
if not shot:
|
if not shot:
|
||||||
self._info_label.setText("")
|
self._info_label.setText("")
|
||||||
|
self._thumb_label.setVisible(False)
|
||||||
return
|
return
|
||||||
parts = []
|
parts = []
|
||||||
if shot.get("description"):
|
if shot.get("description"):
|
||||||
@@ -570,6 +867,73 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
parts.append(status.replace("_", " ").title())
|
parts.append(status.replace("_", " ").title())
|
||||||
self._info_label.setText(" | ".join(parts))
|
self._info_label.setText(" | ".join(parts))
|
||||||
|
|
||||||
|
thumb_url = shot.get("thumbnailUrl") or shot.get("thumbnail") or ""
|
||||||
|
if thumb_url:
|
||||||
|
self._load_thumbnail(thumb_url)
|
||||||
|
else:
|
||||||
|
# Show placeholder immediately; do a background lookup for version thumbnail
|
||||||
|
self._thumb_label.setPixmap(QtGui.QPixmap())
|
||||||
|
self._thumb_label.setText("No thumbnail")
|
||||||
|
self._thumb_label.setVisible(True)
|
||||||
|
shot_code = shot.get("shotCode", "")
|
||||||
|
if shot_code:
|
||||||
|
self._start_version_thumb_lookup(shot_code)
|
||||||
|
|
||||||
|
def _load_thumbnail(self, url: str):
|
||||||
|
"""Start async image fetch. Thread lives in self._threads until finished."""
|
||||||
|
self._thumb_request_id += 1
|
||||||
|
request_id = self._thumb_request_id
|
||||||
|
|
||||||
|
self._thumb_label.setText("Loading thumbnail\u2026")
|
||||||
|
self._thumb_label.setPixmap(QtGui.QPixmap())
|
||||||
|
self._thumb_label.setVisible(True)
|
||||||
|
|
||||||
|
loader = _ThumbnailLoader(url, request_id)
|
||||||
|
loader.loaded.connect(self._on_thumbnail_loaded)
|
||||||
|
loader.finished.connect(lambda: self._threads.discard(loader))
|
||||||
|
self._threads.add(loader)
|
||||||
|
loader.start()
|
||||||
|
|
||||||
|
def _start_version_thumb_lookup(self, shot_code: str):
|
||||||
|
"""Background lookup for version thumbnailUrl when the shot has none."""
|
||||||
|
self._version_fetch_id += 1
|
||||||
|
fetch_id = self._version_fetch_id
|
||||||
|
|
||||||
|
fetcher = _VersionThumbFetcher(shot_code, self._api, fetch_id)
|
||||||
|
fetcher.found.connect(self._on_version_thumb_found)
|
||||||
|
fetcher.finished.connect(lambda: self._threads.discard(fetcher))
|
||||||
|
self._threads.add(fetcher)
|
||||||
|
fetcher.start()
|
||||||
|
|
||||||
|
def _on_version_thumb_found(self, fetch_id: int, url: str):
|
||||||
|
"""Fires on the main thread after version lookup; loads image if found."""
|
||||||
|
if fetch_id != self._version_fetch_id:
|
||||||
|
return # stale -- user moved to a different shot
|
||||||
|
if url:
|
||||||
|
self._load_thumbnail(url)
|
||||||
|
# Otherwise label already shows 'No thumbnail'
|
||||||
|
|
||||||
|
def _on_thumbnail_loaded(self, request_id: int, data: bytes):
|
||||||
|
"""Called on the main thread. Ignores results from superseded requests."""
|
||||||
|
if request_id != self._thumb_request_id:
|
||||||
|
return # stale — user already selected a different shot
|
||||||
|
if not data:
|
||||||
|
self._thumb_label.setText("No thumbnail")
|
||||||
|
self._thumb_label.setPixmap(QtGui.QPixmap())
|
||||||
|
return
|
||||||
|
pixmap = QtGui.QPixmap()
|
||||||
|
if not pixmap.loadFromData(data) or pixmap.isNull():
|
||||||
|
self._thumb_label.setText("No thumbnail")
|
||||||
|
self._thumb_label.setPixmap(QtGui.QPixmap())
|
||||||
|
return
|
||||||
|
# Scale to fit label width, capping height at 120 px
|
||||||
|
label_w = max(self._thumb_label.width(), 200)
|
||||||
|
scaled = pixmap.scaledToWidth(label_w, Qt.SmoothTransformation)
|
||||||
|
if scaled.height() > 120:
|
||||||
|
scaled = pixmap.scaledToHeight(120, Qt.SmoothTransformation)
|
||||||
|
self._thumb_label.setPixmap(scaled)
|
||||||
|
self._thumb_label.setVisible(True)
|
||||||
|
|
||||||
def _build(self):
|
def _build(self):
|
||||||
shot_data = self._shot_combo.currentData()
|
shot_data = self._shot_combo.currentData()
|
||||||
if not shot_data:
|
if not shot_data:
|
||||||
@@ -586,7 +950,7 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
QtWidgets.QApplication.processEvents()
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ok = build_shot(shot_code, self._api)
|
ok, warn = build_shot(shot_code, self._api, self._shot_type_combo.currentText())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.warning_msg.emit(str(exc))
|
self.warning_msg.emit(str(exc))
|
||||||
self.status_msg.emit("Build failed")
|
self.status_msg.emit("Build failed")
|
||||||
@@ -597,10 +961,165 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
|
|||||||
if ok:
|
if ok:
|
||||||
self.shot_built.emit(shot_code)
|
self.shot_built.emit(shot_code)
|
||||||
self.status_msg.emit(f"Built: {shot_code}")
|
self.status_msg.emit(f"Built: {shot_code}")
|
||||||
self.warning_msg.emit("")
|
self.warning_msg.emit(warn) # empty string clears, message shows in amber
|
||||||
else:
|
else:
|
||||||
self.status_msg.emit("Build cancelled or failed")
|
self.status_msg.emit("Build cancelled or failed")
|
||||||
|
|
||||||
|
def _import_renders(self):
|
||||||
|
shot_data = self._shot_combo.currentData()
|
||||||
|
if not shot_data:
|
||||||
|
self.warning_msg.emit("Select a shot first")
|
||||||
|
return
|
||||||
|
|
||||||
|
shot_code = shot_data.get("shotCode", "")
|
||||||
|
if not shot_code:
|
||||||
|
self.warning_msg.emit("Invalid shot selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.status_msg.emit(f"Importing renders for {shot_code}…")
|
||||||
|
self._import_btn.setEnabled(False)
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok, msg = import_renders(shot_code)
|
||||||
|
except Exception as exc:
|
||||||
|
self.warning_msg.emit(str(exc))
|
||||||
|
self.status_msg.emit("Import failed")
|
||||||
|
self._import_btn.setEnabled(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._import_btn.setEnabled(True)
|
||||||
|
if ok:
|
||||||
|
self.status_msg.emit(msg)
|
||||||
|
self.warning_msg.emit("")
|
||||||
|
else:
|
||||||
|
self.status_msg.emit("Import failed")
|
||||||
|
self.warning_msg.emit(msg)
|
||||||
|
|
||||||
|
def _pull_piclock(self):
|
||||||
|
shot_data = self._shot_combo.currentData()
|
||||||
|
if not shot_data:
|
||||||
|
self.warning_msg.emit("Select a shot first")
|
||||||
|
return
|
||||||
|
|
||||||
|
shot_code = shot_data.get("shotCode", "")
|
||||||
|
if not shot_code:
|
||||||
|
self.warning_msg.emit("Invalid shot selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.status_msg.emit(f"Pulling piclock for {shot_code}\u2026")
|
||||||
|
self._piclock_btn.setEnabled(False)
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok, msg = pull_piclock(shot_code, self._api)
|
||||||
|
except Exception as exc:
|
||||||
|
self.warning_msg.emit(str(exc))
|
||||||
|
self.status_msg.emit("Piclock failed")
|
||||||
|
self._piclock_btn.setEnabled(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._piclock_btn.setEnabled(True)
|
||||||
|
if ok:
|
||||||
|
self.status_msg.emit(msg)
|
||||||
|
self.warning_msg.emit("")
|
||||||
|
else:
|
||||||
|
self.status_msg.emit("Piclock failed")
|
||||||
|
self.warning_msg.emit(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _CollapsibleSection(QtWidgets.QWidget):
|
||||||
|
"""A titled section that expands/collapses when its header button is clicked."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._title = title
|
||||||
|
|
||||||
|
outer = QtWidgets.QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
outer.setSpacing(0)
|
||||||
|
|
||||||
|
self._btn = QtWidgets.QPushButton(f"▶ {title}")
|
||||||
|
self._btn.setCheckable(True)
|
||||||
|
self._btn.setChecked(False)
|
||||||
|
self._btn.setStyleSheet(
|
||||||
|
"QPushButton { text-align: left; padding: 4px 8px; "
|
||||||
|
"background: #2E2E2E; border: 1px solid #555; font-weight: bold; }"
|
||||||
|
"QPushButton:checked { background: #383848; }"
|
||||||
|
)
|
||||||
|
self._btn.toggled.connect(self._on_toggle)
|
||||||
|
outer.addWidget(self._btn)
|
||||||
|
|
||||||
|
self._body = QtWidgets.QWidget()
|
||||||
|
self._body_layout = QtWidgets.QVBoxLayout(self._body)
|
||||||
|
self._body_layout.setContentsMargins(8, 6, 8, 8)
|
||||||
|
self._body_layout.setSpacing(4)
|
||||||
|
self._body.setVisible(False)
|
||||||
|
outer.addWidget(self._body)
|
||||||
|
|
||||||
|
def _on_toggle(self, checked: bool):
|
||||||
|
arrow = u"\u25bc" if checked else u"\u25b6"
|
||||||
|
self._btn.setText(f"{arrow} {self._title}")
|
||||||
|
self._body.setVisible(checked)
|
||||||
|
|
||||||
|
def content_layout(self) -> QtWidgets.QVBoxLayout:
|
||||||
|
return self._body_layout
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsWidget(_CollapsibleSection):
|
||||||
|
"""Collapsible panel section for configuring FOOTAGE_ROOT and EXPORT_ROOT."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__("Settings", parent)
|
||||||
|
body = self.content_layout()
|
||||||
|
|
||||||
|
# ── Footage Root ──────────────────────────────────────────────────────
|
||||||
|
body.addWidget(QtWidgets.QLabel("Footage Root:"))
|
||||||
|
footage_row = QtWidgets.QHBoxLayout()
|
||||||
|
self._footage_edit = QtWidgets.QLineEdit(FOOTAGE_ROOT)
|
||||||
|
self._footage_edit.setPlaceholderText("V:/_FOOTAGE/UNG")
|
||||||
|
self._footage_browse = QtWidgets.QPushButton("…")
|
||||||
|
self._footage_browse.setFixedWidth(26)
|
||||||
|
footage_row.addWidget(self._footage_edit)
|
||||||
|
footage_row.addWidget(self._footage_browse)
|
||||||
|
body.addLayout(footage_row)
|
||||||
|
|
||||||
|
# ── Export Root ───────────────────────────────────────────────────────
|
||||||
|
body.addWidget(QtWidgets.QLabel("Export Root:"))
|
||||||
|
export_row = QtWidgets.QHBoxLayout()
|
||||||
|
self._export_edit = QtWidgets.QLineEdit(EXPORT_ROOT)
|
||||||
|
self._export_edit.setPlaceholderText("V:/_EXPORTS/UNG")
|
||||||
|
self._export_browse = QtWidgets.QPushButton("…")
|
||||||
|
self._export_browse.setFixedWidth(26)
|
||||||
|
export_row.addWidget(self._export_edit)
|
||||||
|
export_row.addWidget(self._export_browse)
|
||||||
|
body.addLayout(export_row)
|
||||||
|
|
||||||
|
# ── Wire ──────────────────────────────────────────────────────────────
|
||||||
|
self._footage_edit.textChanged.connect(self._update_globals)
|
||||||
|
self._export_edit.textChanged.connect(self._update_globals)
|
||||||
|
self._footage_browse.clicked.connect(self._browse_footage)
|
||||||
|
self._export_browse.clicked.connect(self._browse_export)
|
||||||
|
|
||||||
|
def _update_globals(self):
|
||||||
|
global FOOTAGE_ROOT, EXPORT_ROOT
|
||||||
|
FOOTAGE_ROOT = self._footage_edit.text().strip()
|
||||||
|
EXPORT_ROOT = self._export_edit.text().strip()
|
||||||
|
|
||||||
|
def _browse_footage(self):
|
||||||
|
path = QtWidgets.QFileDialog.getExistingDirectory(
|
||||||
|
self, "Select Footage Root", self._footage_edit.text()
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self._footage_edit.setText(path)
|
||||||
|
|
||||||
|
def _browse_export(self):
|
||||||
|
path = QtWidgets.QFileDialog.getExistingDirectory(
|
||||||
|
self, "Select Export Root", self._export_edit.text()
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self._export_edit.setText(path)
|
||||||
|
|
||||||
|
|
||||||
class VFXReviewPanel(QtWidgets.QWidget):
|
class VFXReviewPanel(QtWidgets.QWidget):
|
||||||
"""Main dockable panel widget."""
|
"""Main dockable panel widget."""
|
||||||
@@ -623,7 +1142,9 @@ class VFXReviewPanel(QtWidgets.QWidget):
|
|||||||
# ── Connection ────────────────────────────────────────────────────────
|
# ── Connection ────────────────────────────────────────────────────────
|
||||||
self._connection = ConnectionWidget(self._api)
|
self._connection = ConnectionWidget(self._api)
|
||||||
layout.addWidget(self._connection)
|
layout.addWidget(self._connection)
|
||||||
|
# ── Settings (collapsible) ───────────────────────────────────────────
|
||||||
|
self._settings = SettingsWidget()
|
||||||
|
layout.addWidget(self._settings)
|
||||||
# ── Shot Builder ──────────────────────────────────────────────────────
|
# ── Shot Builder ──────────────────────────────────────────────────────
|
||||||
self._shot_builder = ShotBuilderWidget(self._api)
|
self._shot_builder = ShotBuilderWidget(self._api)
|
||||||
layout.addWidget(self._shot_builder)
|
layout.addWidget(self._shot_builder)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
approved,
|
||||||
|
review,
|
||||||
|
changes,
|
||||||
|
todo,
|
||||||
|
artists,
|
||||||
|
shots,
|
||||||
|
renderQueue,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.shot.count({
|
||||||
|
where: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||||
|
}),
|
||||||
|
db.shot.count({
|
||||||
|
where: { status: { in: ["INTERNAL_REVIEW", "READY_FOR_CLIENT", "CLIENT_REVIEW"] } },
|
||||||
|
}),
|
||||||
|
db.shot.count({
|
||||||
|
where: { status: "REVISIONS" },
|
||||||
|
}),
|
||||||
|
db.shot.count({
|
||||||
|
where: { status: "WAITING" },
|
||||||
|
}),
|
||||||
|
db.user.count({
|
||||||
|
where: { role: "ARTIST", isActive: true },
|
||||||
|
}),
|
||||||
|
db.shot.count({}),
|
||||||
|
db.task.count({
|
||||||
|
where: { type: "RENDER", status: { not: "DONE" } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
approved,
|
||||||
|
review,
|
||||||
|
changes,
|
||||||
|
todo,
|
||||||
|
artists,
|
||||||
|
shots,
|
||||||
|
renderQueue,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ export async function GET(req: NextRequest) {
|
|||||||
approvalStatus: true,
|
approvalStatus: true,
|
||||||
reviewStatus: true,
|
reviewStatus: true,
|
||||||
fileUrl: true,
|
fileUrl: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user