11from __future__ import annotations
22
33import atexit
4- import contextlib
54import json
65import logging
76import os
109import subprocess
1110import tempfile
1211import urllib .request
12+ import uuid
1313from typing import Any
1414
1515from griptape_nodes .common .macro_parser import ParsedMacro
2020from griptape_nodes .exe_types .param_types .parameter_string import ParameterString
2121from griptape_nodes .files .file import File
2222from griptape_nodes .files .project_file import ProjectFileDestination
23+ from griptape_nodes .retained_mode .events .os_events import DeleteFileRequest , WriteFileRequest , WriteFileResultSuccess
2324from griptape_nodes .retained_mode .events .project_events import GetPathForMacroRequest , GetPathForMacroResultSuccess
2425from griptape_nodes .retained_mode .griptape_nodes import GriptapeNodes
2526from 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