Skip to content

Commit 6b0a210

Browse files
committed
feat: migrate NukeScriptNode scratch files to save_temp_file project situation
Replaces raw tempfile calls in nuke_nodes/ with project-managed paths via ProjectFileDestination.from_situation("save_temp_file"), WriteFileRequest, and DeleteFileRequest — making scratch files visible to the project system and deterministic across re-runs (OVERWRITE policy). - _artifact_to_path(): URL/bytes branches now use WriteFileRequest to write downloaded content; accepts name and _cleanup params for deterministic naming and caller-managed cleanup - process(): image sequence directory created via WriteFileRequest .empty sentinel (auto-creates parents); output placeholders likewise; input and output scratch paths tracked and cleaned up via DeleteFileRequest in finally - Removed import contextlib; added DeleteFileRequest, WriteFileRequest imports - Tests: mocked ProjectFileDestination/GriptapeNodes throughout; added bytes and cleanup-list coverage; updated sequence test for new from_situation calls
1 parent 9e4072a commit 6b0a210

2 files changed

Lines changed: 283 additions & 63 deletions

File tree

nuke_nodes/nuke_script_node.py

Lines changed: 99 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import atexit
4-
import contextlib
54
import json
65
import logging
76
import os
@@ -10,6 +9,7 @@
109
import subprocess
1110
import tempfile
1211
import urllib.request
12+
import uuid
1313
from typing import Any
1414

1515
from griptape_nodes.common.macro_parser import ParsedMacro
@@ -20,6 +20,7 @@
2020
from griptape_nodes.exe_types.param_types.parameter_string import ParameterString
2121
from griptape_nodes.files.file import File
2222
from griptape_nodes.files.project_file import ProjectFileDestination
23+
from griptape_nodes.retained_mode.events.os_events import DeleteFileRequest, WriteFileRequest, WriteFileResultSuccess
2324
from griptape_nodes.retained_mode.events.project_events import GetPathForMacroRequest, GetPathForMacroResultSuccess
2425
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
2526
from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload
@@ -612,7 +613,13 @@ def _ensure_annotations(self) -> None:
612613
# Artifact / path resolution
613614
# ------------------------------------------------------------------
614615

615-
def _artifact_to_path(self, value: Any) -> str:
616+
def _artifact_to_path(
617+
self,
618+
value: Any,
619+
name: str = "input",
620+
_cleanup: list[str] | None = None,
621+
situation: str = "save_temp_file",
622+
) -> str:
616623
"""Resolve a parameter value to a local file path Nuke can read."""
617624
if isinstance(value, str):
618625
return value
@@ -627,16 +634,17 @@ def _artifact_to_path(self, value: Any) -> str:
627634
if val.startswith(("http://", "https://")):
628635
detected = os.path.splitext(val.split("?")[0])[-1]
629636
suffix = detected or (".mp4" if isinstance(value, _video_type) else ".png")
630-
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
631-
try:
632-
with urllib.request.urlopen(val, timeout=30) as response: # noqa: S310
633-
tmp.write(response.read())
634-
tmp.close()
635-
except Exception:
636-
tmp.close()
637-
os.unlink(tmp.name)
638-
raise
639-
return tmp.name
637+
_id = uuid.uuid4().hex[:8]
638+
# Download the remote asset into a project-managed file so Nuke can read it
639+
# by local path. UUID prefix avoids collisions when multiple nodes run in
640+
# parallel. final_file_path (not resolve()) is used because the framework may
641+
# rename the file under CREATE_NEW collision policy.
642+
dest = ProjectFileDestination.from_situation(f"{self.name}_{name}_{_id}{suffix}", situation)
643+
with urllib.request.urlopen(val, timeout=30) as response: # noqa: S310
644+
tmp_path = self._write_scratch_file(str(dest.resolve()), response.read())
645+
if _cleanup is not None:
646+
_cleanup.append(tmp_path)
647+
return tmp_path
640648
if "{" in val:
641649
try:
642650
macro = ParsedMacro(val)
@@ -648,12 +656,27 @@ def _artifact_to_path(self, value: Any) -> str:
648656
return str(result.absolute_path)
649657
return val
650658
if isinstance(val, bytes):
651-
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
652-
tmp.write(val)
653-
tmp.close()
654-
return tmp.name
659+
_id = uuid.uuid4().hex[:8]
660+
# Materialise raw bytes (e.g. BlobArtifact image data) to a file Nuke can open.
661+
# Same UUID + situation strategy as the URL branch above.
662+
dest = ProjectFileDestination.from_situation(f"{self.name}_{name}_{_id}.png", situation)
663+
tmp_path = self._write_scratch_file(str(dest.resolve()), val)
664+
if _cleanup is not None:
665+
_cleanup.append(tmp_path)
666+
return tmp_path
655667
return str(value)
656668

669+
def _write_scratch_file(self, path: str, content: bytes) -> str:
670+
"""Write content and return the canonical path actually written.
671+
672+
The framework may rename the file (e.g. foo_1.png) under a CREATE_NEW collision
673+
policy, so callers must use final_file_path rather than the path they passed in.
674+
"""
675+
result = GriptapeNodes.handle_request(WriteFileRequest(file_path=path, content=content))
676+
if not isinstance(result, WriteFileResultSuccess):
677+
raise RuntimeError(f"Failed to write scratch file: {path}")
678+
return result.final_file_path
679+
657680
def _build_env(self, installation: NukeInstallation | None) -> dict[str, str]:
658681
"""Merge global env settings, installation overrides, and foundry_LICENSE from os.environ."""
659682
global_env: dict[str, str] = GriptapeNodes.ConfigManager().get_config_value("nuke.env") or {}
@@ -733,8 +756,10 @@ def _save_baked_copy(
733756
if ann.role == "input":
734757
raw = self.get_parameter_value(ann.gt_name) or ""
735758
if raw:
759+
# Paths are baked as absolute references into the .nk copy, so they must
760+
# outlive this call. Persist them as node outputs, not temp files.
736761
inputs[ann.gt_name] = ManifestInput(
737-
path=self._artifact_to_path(raw),
762+
path=self._artifact_to_path(raw, name=ann.gt_name, situation="save_node_output"),
738763
node=ann.node_name,
739764
)
740765

@@ -789,44 +814,65 @@ def process(self) -> None:
789814

790815
inputs: dict[str, ManifestInput] = {}
791816
outputs: dict[str, ManifestOutput] = {}
817+
input_tmp_paths: list[str] = []
792818
output_tmp_paths: list[str] = []
793819
output_tmp_dirs: list[str] = []
820+
_run_id = uuid.uuid4().hex[:8]
794821

795-
for ann in self._annotations:
796-
if ann.role == "input":
797-
inputs[ann.gt_name] = ManifestInput(
798-
path=self._artifact_to_path(self.get_parameter_value(ann.gt_name) or ""),
799-
node=ann.node_name,
800-
)
801-
elif ann.gt_type == "ImageSequenceArtifact":
802-
tmpdir = tempfile.mkdtemp(prefix="griptape_nuke_seq_")
803-
output_tmp_dirs.append(tmpdir)
804-
seq_path = os.path.join(tmpdir, "frame.%04d.png").replace("\\", "/")
805-
outputs[ann.gt_name] = ManifestOutput(
806-
path=seq_path,
807-
node=ann.node_name,
808-
type="ImageSequenceArtifact",
809-
)
810-
else:
811-
suffix = _TEMP_SUFFIX.get(ann.gt_type or "", ".png")
812-
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
813-
tmp.close()
814-
output_tmp_paths.append(tmp.name)
815-
outputs[ann.gt_name] = ManifestOutput(
816-
path=tmp.name,
817-
node=ann.node_name,
818-
type=ann.gt_type or "ImageArtifact",
819-
)
822+
try:
823+
for ann in self._annotations:
824+
if ann.role == "input":
825+
inputs[ann.gt_name] = ManifestInput(
826+
path=self._artifact_to_path(
827+
self.get_parameter_value(ann.gt_name) or "",
828+
name=ann.gt_name,
829+
_cleanup=input_tmp_paths,
830+
),
831+
node=ann.node_name,
832+
)
833+
elif ann.gt_type == "ImageSequenceArtifact":
834+
seq_dest = ProjectFileDestination.from_situation(
835+
f"{self.name}_{_run_id}_{ann.gt_name}_frame.png", "save_temp_file"
836+
)
837+
# Nuke requires the output directory to exist before it starts writing
838+
# frames. WriteFileRequest has no mkdir equivalent, so we force creation
839+
# by writing a sentinel .empty file. seq_dir is derived from final_file_path
840+
# (not resolve()) in case the framework renamed the directory under
841+
# CREATE_NEW collision policy.
842+
_empty_path = self._write_scratch_file(str(pathlib.Path(seq_dest.resolve()).parent / ".empty"), b"")
843+
seq_dir = str(pathlib.Path(_empty_path).parent)
844+
seq_path = f"{seq_dir}/{ann.gt_name}_frame.%04d.png".replace("\\", "/")
845+
output_tmp_dirs.append(seq_dir)
846+
outputs[ann.gt_name] = ManifestOutput(
847+
path=seq_path,
848+
node=ann.node_name,
849+
type="ImageSequenceArtifact",
850+
)
851+
else:
852+
suffix = _TEMP_SUFFIX.get(ann.gt_type or "", ".png")
853+
placeholder_dest = ProjectFileDestination.from_situation(
854+
f"{self.name}_{_run_id}_{ann.gt_name}{suffix}", "save_temp_file"
855+
)
856+
# Pre-create the output file so Nuke has a known path to write into.
857+
# The runner overwrites this placeholder; after the run _path_to_artifact
858+
# reads it and re-saves to save_node_output so downstream nodes get a
859+
# persistent URL. The placeholder is cleaned up in the finally block.
860+
placeholder_path = self._write_scratch_file(str(placeholder_dest.resolve()), b"")
861+
output_tmp_paths.append(placeholder_path)
862+
outputs[ann.gt_name] = ManifestOutput(
863+
path=placeholder_path,
864+
node=ann.node_name,
865+
type=ann.gt_type or "ImageArtifact",
866+
)
820867

821-
knob_overrides: list[KnobOverride] = []
822-
for ek in self._expose_knobs:
823-
raw = self.get_parameter_value(ek.param_name)
824-
if raw not in (None, ""):
825-
knob_overrides.append(
826-
KnobOverride(node=ek.target_node, knob=ek.target_knob, value=_coerce_knob_value(raw))
827-
)
868+
knob_overrides: list[KnobOverride] = []
869+
for ek in self._expose_knobs:
870+
raw = self.get_parameter_value(ek.param_name)
871+
if raw not in (None, ""):
872+
knob_overrides.append(
873+
KnobOverride(node=ek.target_node, knob=ek.target_knob, value=_coerce_knob_value(raw))
874+
)
828875

829-
try:
830876
manifest = JobManifest(
831877
script=script_path,
832878
inputs=inputs,
@@ -855,9 +901,7 @@ def process(self) -> None:
855901

856902
self._set_status_results(was_successful=True, result_details="Render complete.")
857903
finally:
858-
for p in output_tmp_paths:
859-
with contextlib.suppress(OSError):
860-
os.unlink(p)
904+
for p in input_tmp_paths + output_tmp_paths:
905+
GriptapeNodes.handle_request(DeleteFileRequest(path=p, workspace_only=False))
861906
for d in output_tmp_dirs:
862-
with contextlib.suppress(OSError):
863-
shutil.rmtree(d)
907+
GriptapeNodes.handle_request(DeleteFileRequest(path=d, workspace_only=False))

0 commit comments

Comments
 (0)