diff --git a/Plugins/nosTrack/CHANGES.md b/Plugins/nosTrack/CHANGES.md new file mode 100644 index 00000000..ec713c45 --- /dev/null +++ b/Plugins/nosTrack/CHANGES.md @@ -0,0 +1,57 @@ +# Record Track (COLMAP) Node + +## Summary + +A new node "Record Track (COLMAP)" added to the `nosTrack` plugin. It records incoming camera tracking data per frame and exports it in COLMAP's text format (`cameras.txt` + `images.txt`). + +## Files Changed + +### New files +- `Source/RecordTrackCOLMAP.cpp` — Node implementation +- `Config/RecordTrackCOLMAP.nosdef` — Node definition (pins, functions, metadata) + +### Modified files +- `Source/TrackMain.cpp` — Added `RecordTrackCOLMAP` to the `TrackNode` enum and `ExportNodeFunctions` switch +- `Track.noscfg` — Added `Config/RecordTrackCOLMAP.nosdef` to `node_definitions` + +## Node Design + +### Pins +| Pin | Type | Direction | Description | +|-----|------|-----------|-------------| +| Track | `nos.track.Track` | Input | Incoming tracking data | +| Track Out | `nos.track.Track` | Output (only) | Pass-through of input | +| Output Directory | `string` | Property | Folder picker for output | +| Image Resolution | `nos.fb.vec2u` | Property | Width/height (default 1920x1080) | +| Record | `bool` | Property | Mirrors Record/Stop functions | +| Frame Count | `uint` | Output (only) | Frames in buffer | + +### Functions +| Function | Behavior | +|----------|----------| +| Record | Validates folder is empty, clears buffer, starts recording. Orphaned while recording. | +| Stop | Stops recording (does NOT save). Orphaned while idle. | +| Save | Writes `cameras.txt` + `images.txt` to disk. Does not clear buffer. | +| Clear | Clears frame buffer and resets count. | +| Open Folder | Opens output directory in explorer (Windows) or xdg-open (Linux). | + +### State Management +- Record pin and functions are kept in sync bidirectionally. A `SyncingRecordPin` bool guard prevents re-entrant loops between pin changes and function calls. +- Function orphan states: Record/Stop toggle via `SetNodeOrphanState` using a `Name -> UUID` map built in constructor. +- Status messages show recording state + frame count, and persist error messages (e.g., "Target folder is not empty") via `LastError` until user changes the output directory. +- Non-empty folder check: Recording fails with a FAILURE status if the target folder already has files. + +### COLMAP Output Format +- `cameras.txt`: One OPENCV camera per frame — `fx, fy, cx, cy, k1, k2, p1, p2` derived from Track FOV, sensor size, pixel aspect ratio, lens distortion. +- `images.txt`: Per-frame pose — Euler angles converted to quaternion (world-to-camera), translation as `t = -R * C`. + +## Known Review Points +- Euler-to-quaternion convention: The Track's rotation fields (roll/tilt/pan) are passed through `glm::quat(eulerRadians)` then inverted for COLMAP's world-to-camera convention. May need validation against actual tracker output. +- One camera per frame: Each frame gets its own camera entry. This handles zoom/FOV changes but may be unusual for COLMAP workflows with constant intrinsics. +- No `points3D.txt`: COLMAP expects this file too (can be empty). Not currently written. +- `std::system()` for Open Folder: Works but is a simple shell call. Could be replaced with platform APIs if needed. + +## Build +``` +./nodos dev build -p Project13 --target nosTrack +``` diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef new file mode 100644 index 00000000..67b8f74e --- /dev/null +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -0,0 +1,81 @@ +{ + "nodes": [ + { + "class_name": "PlaybackTrackCOLMAP", + "menu_info": { + "category": "nosTrack", + "display_name": "Playback Track (COLMAP)", + "name_aliases": [ "colmap", "import camera", "playback camera" ] + }, + "node": { + "class_name": "PlaybackTrackCOLMAP", + "display_name": "Playback Track (COLMAP)", + "contents_type": "Job", + "always_execute": true, + "description": "Loads camera tracking data from COLMAP text format (cameras.txt + images.txt) and outputs Track data at a given frame index.", + "pins": [ + { + "name": "InputDirectory", + "display_name": "Input Directory", + "type_name": "string", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { "type": "FOLDER_PICKER" }, + "description": "Directory containing cameras.txt and images.txt in COLMAP text format." + }, + { + "name": "CoordinateSystem", + "display_name": "Coordinate System", + "type_name": "nos.sys.track.CoordinateSystem", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "ZYX", + "description": "Euler angle rotation order for converting COLMAP quaternion to Track rotation. Default ZYX matches the FreeD node convention." + }, + { + "name": "InFrameIndex", + "display_name": "Frame Index", + "type_name": "uint", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0, + "description": "Frame index to output." + }, + { + "name": "Track", + "type_name": "nos.sys.track.Track", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "description": "Track data for the current frame." + }, + { + "name": "OutFrameIndex", + "display_name": "Frame Index", + "type_name": "uint", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": 0, + "description": "Current playback frame index." + }, + { + "name": "FrameCount", + "display_name": "Frame Count", + "type_name": "uint", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": 0, + "description": "Total number of frames loaded." + } + ], + "functions": [ + { + "class_name": "PlaybackTrackCOLMAP_OpenFolder", + "display_name": "Open Folder", + "contents_type": "Job", + "pins": [] + } + ] + } + } + ] +} diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef new file mode 100644 index 00000000..2237b720 --- /dev/null +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -0,0 +1,125 @@ +{ + "nodes": [ + { + "class_name": "RecordTrackCOLMAP", + "menu_info": { + "category": "nosTrack", + "display_name": "Record Track (COLMAP)", + "name_aliases": [ "colmap", "export camera", "record camera" ] + }, + "node": { + "class_name": "RecordTrackCOLMAP", + "display_name": "Record Track (COLMAP)", + "contents_type": "Job", + "always_execute": true, + "description": "Records camera tracking data each frame while recording is enabled, then exports cameras.txt and images.txt in COLMAP format. Intrinsics (focal length, distortion) are derived from the Track's FOV, sensor size, and lens distortion. Extrinsics (rotation, translation) are stored per frame in world-to-camera convention.", + "pins": [ + { + "name": "InTrack", + "display_name": "Track", + "type_name": "nos.sys.track.Track", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "description": "Incoming camera tracking data to record. Position, rotation, FOV, sensor size, and lens distortion are captured each frame." + }, + { + "name": "OutTrack", + "display_name": "Track", + "type_name": "nos.sys.track.Track", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "description": "Pass-through of the incoming Track data." + }, + { + "name": "OutputDirectory", + "display_name": "Output Directory", + "type_name": "string", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { "type": "FOLDER_PICKER" }, + "description": "Directory where cameras.txt and images.txt will be written when recording stops. Must be empty to start recording." + }, + { + "name": "ImageResolution", + "display_name": "Image Resolution", + "type_name": "nos.fb.vec2u", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "x": 1920, + "y": 1080 + }, + "description": "Image resolution in pixels (width, height). Used to compute focal length and principal point for COLMAP camera model." + }, + { + "name": "CoordinateSystem", + "display_name": "Coordinate System", + "type_name": "nos.sys.track.CoordinateSystem", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "ZYX", + "description": "Euler angle rotation order used when converting Track rotation to COLMAP extrinsics. Default ZYX matches the FreeD node convention." + }, + { + "name": "Record", + "type_name": "bool", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": false, + "description": "Toggle recording. Mirrors Record/Stop functions. Enabling clears previous frames and starts capturing. Will fail if the output directory is not empty." + }, + { + "name": "RecordingFrame", + "display_name": "Recording Frame", + "type_name": "uint", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": 0, + "description": "Current recording frame index. Outputs 0 when not recording." + }, + { + "name": "FrameCount", + "display_name": "Frame Count", + "type_name": "uint", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": 0, + "description": "Number of frames in the buffer." + } + ], + "functions": [ + { + "class_name": "RecordTrackCOLMAP_Record", + "display_name": "Record", + "contents_type": "Job", + "pins": [] + }, + { + "class_name": "RecordTrackCOLMAP_Stop", + "display_name": "Stop", + "contents_type": "Job", + "pins": [] + }, + { + "class_name": "RecordTrackCOLMAP_Save", + "display_name": "Save", + "contents_type": "Job", + "pins": [] + }, + { + "class_name": "RecordTrackCOLMAP_Clear", + "display_name": "Clear", + "contents_type": "Job", + "pins": [] + }, + { + "class_name": "RecordTrackCOLMAP_OpenFolder", + "display_name": "Open Folder", + "contents_type": "Job", + "pins": [] + } + ] + } + } + ] +} diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp new file mode 100644 index 00000000..567f929b --- /dev/null +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -0,0 +1,363 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include "nosSysTrack/Track_generated.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace nos::track +{ + +NOS_REGISTER_NAME_SPACED(Playback_InputDirectory, "InputDirectory"); +NOS_REGISTER_NAME_SPACED(Playback_CoordinateSystem, "CoordinateSystem"); +NOS_REGISTER_NAME_SPACED(Playback_InFrameIndex, "InFrameIndex"); +NOS_REGISTER_NAME_SPACED(Playback_OutFrameIndex, "OutFrameIndex"); +NOS_REGISTER_NAME_SPACED(Playback_FrameCount, "FrameCount"); + +NOS_REGISTER_NAME(PlaybackTrackCOLMAP_OpenFolder); + +struct COLMAPCamera +{ + uint32_t Id = 0; + std::string Model; + uint32_t Width = 0; + uint32_t Height = 0; + float Fx = 0, Fy = 0, Cx = 0, Cy = 0; + float K1 = 0, K2 = 0, P1 = 0, P2 = 0; +}; + +struct COLMAPImage +{ + uint32_t Id = 0; + glm::quat Q{1, 0, 0, 0}; + glm::vec3 T{0}; + uint32_t CameraId = 0; +}; + +struct PlaybackTrackCOLMAPContext : NodeContext +{ + std::string InputDir; + sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; + uint32_t FrameIndex = 0; + std::string LastError; + std::vector Frames; + uint32_t CurrentFrame = 0; + PlaybackTrackCOLMAPContext(nosFbNodePtr node) : NodeContext(node) + { + if (node->pins()) + { + for (auto* pin : *node->pins()) + { + auto name = nos::Name(pin->name()->c_str()); + if (flatbuffers::IsFieldPresent(pin, fb::Pin::VT_DATA)) + { + nosBuffer value = {.Data = (void*)pin->data()->data(), .Size = pin->data()->size()}; + OnPinValueChanged(name, *pin->id(), value); + } + } + } + UpdateStatus(); + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer val) override + { + if (pinName == NSN_Playback_InputDirectory) + { + InputDir = InterpretPinValue(val.Data); + LastError.clear(); + if (!InputDir.empty()) + LoadFromDirectory(); + else + UpdateStatus(); + } + else if (pinName == NSN_Playback_CoordinateSystem) + { + CoordSys = *(sys::track::CoordinateSystem*)val.Data; + if (!InputDir.empty()) + LoadFromDirectory(); + } + else if (pinName == NSN_Playback_InFrameIndex) + FrameIndex = *(uint32_t*)val.Data; + } + + void UpdateFrameCountPin() + { + uint32_t count = (uint32_t)Frames.size(); + SetPinValue(NSN_Playback_FrameCount, nosBuffer{.Data = &count, .Size = sizeof(count)}); + } + + void UpdateFrameIndexPin() + { + SetPinValue(NSN_Playback_OutFrameIndex, nosBuffer{.Data = &CurrentFrame, .Size = sizeof(CurrentFrame)}); + } + + void UpdateStatus() + { + if (!LastError.empty()) + SetNodeStatusMessage(LastError, fb::NodeStatusMessageType::FAILURE); + else if (InputDir.empty()) + SetNodeStatusMessage("Set input directory", fb::NodeStatusMessageType::WARNING); + else if (Frames.empty()) + SetNodeStatusMessage("No data loaded", fb::NodeStatusMessageType::WARNING); + else + SetNodeStatusMessage("Loaded (" + std::to_string(Frames.size()) + " frames)", fb::NodeStatusMessageType::INFO); + } + + // --- Parsing --- + + bool LoadFromDirectory() + { + if (InputDir.empty()) + { + LastError = "Set input directory"; + UpdateStatus(); + return false; + } + + std::filesystem::path dir = nos::Utf8ToPath(InputDir); + auto camerasPath = dir / "cameras.txt"; + auto imagesPath = dir / "images.txt"; + + if (!std::filesystem::exists(camerasPath)) + { + LastError = "cameras.txt not found"; + UpdateStatus(); + return false; + } + if (!std::filesystem::exists(imagesPath)) + { + LastError = "images.txt not found"; + UpdateStatus(); + return false; + } + + std::unordered_map cameras; + if (!ParseCamerasTxt(camerasPath, cameras)) + return false; + + std::vector images; + if (!ParseImagesTxt(imagesPath, images)) + return false; + + if (images.empty()) + { + LastError = "No images found in images.txt"; + UpdateStatus(); + return false; + } + + Frames.clear(); + Frames.reserve(images.size()); + + for (auto& img : images) + { + sys::track::TTrack trackData{}; + auto camIt = cameras.find(img.CameraId); + + // Convert COLMAP world-to-camera back to camera-to-world + glm::mat3 R_w2c = glm::mat3_cast(img.Q); + glm::mat3 R_c2w = glm::transpose(R_w2c); + glm::vec3 C = -R_c2w * img.T; + + glm::vec3 euler = RotationMatrixToEuler(R_c2w, CoordSys); + trackData.location = reinterpret_cast(C); + trackData.rotation = reinterpret_cast(euler); + + if (camIt != cameras.end()) + { + auto& cam = camIt->second; + if (cam.Fx > 0) + trackData.fov = glm::degrees(2.0f * std::atan(cam.Width * 0.5f / cam.Fx)); + trackData.sensor_size = nos::fb::vec2(cam.Width, cam.Height); + if (cam.Fx > 0 && cam.Fy > 0) + trackData.pixel_aspect_ratio = cam.Fx / cam.Fy; + trackData.lens_distortion.mutable_k1k2() = nos::fb::vec2(cam.K1, cam.K2); + } + + Frames.push_back(std::move(trackData)); + } + + CurrentFrame = 0; + LastError.clear(); + UpdateFrameCountPin(); + UpdateFrameIndexPin(); + UpdateStatus(); + nosEngine.LogI("PlaybackTrackCOLMAP: Loaded %zu frames from %s", Frames.size(), InputDir.c_str()); + return true; + } + + bool ParseCamerasTxt(const std::filesystem::path& path, std::unordered_map& cameras) + { + std::ifstream file(path); + if (!file.is_open()) + { + LastError = "Cannot open cameras.txt"; + UpdateStatus(); + return false; + } + + std::string line; + while (std::getline(file, line)) + { + if (line.empty() || line[0] == '#') + continue; + std::istringstream ss(line); + COLMAPCamera cam; + ss >> cam.Id >> cam.Model >> cam.Width >> cam.Height; + if (cam.Model == "OPENCV") + ss >> cam.Fx >> cam.Fy >> cam.Cx >> cam.Cy >> cam.K1 >> cam.K2 >> cam.P1 >> cam.P2; + else if (cam.Model == "PINHOLE") + ss >> cam.Fx >> cam.Fy >> cam.Cx >> cam.Cy; + else if (cam.Model == "SIMPLE_PINHOLE") + { + float f; + ss >> f >> cam.Cx >> cam.Cy; + cam.Fx = cam.Fy = f; + } + else if (cam.Model == "SIMPLE_RADIAL") + { + float f; + ss >> f >> cam.Cx >> cam.Cy >> cam.K1; + cam.Fx = cam.Fy = f; + } + else if (cam.Model == "RADIAL") + { + float f; + ss >> f >> cam.Cx >> cam.Cy >> cam.K1 >> cam.K2; + cam.Fx = cam.Fy = f; + } + else + { + nosEngine.LogW("PlaybackTrackCOLMAP: Unsupported camera model '%s', treating as PINHOLE", cam.Model.c_str()); + ss >> cam.Fx >> cam.Fy >> cam.Cx >> cam.Cy; + } + cameras[cam.Id] = cam; + } + return true; + } + + bool ParseImagesTxt(const std::filesystem::path& path, std::vector& images) + { + std::ifstream file(path); + if (!file.is_open()) + { + LastError = "Cannot open images.txt"; + UpdateStatus(); + return false; + } + + std::string line; + while (std::getline(file, line)) + { + if (line.empty() || line[0] == '#') + continue; + std::istringstream ss(line); + COLMAPImage img; + float qw, qx, qy, qz; + std::string name; + ss >> img.Id >> qw >> qx >> qy >> qz + >> img.T.x >> img.T.y >> img.T.z + >> img.CameraId >> name; + img.Q = glm::quat(qw, qx, qy, qz); + images.push_back(img); + // Skip POINTS2D line + std::getline(file, line); + } + + std::sort(images.begin(), images.end(), [](auto& a, auto& b) { return a.Id < b.Id; }); + return true; + } + + // --- Euler extraction (inverse of EulerToRotationMatrix in RecordTrackCOLMAP) --- + + static glm::vec3 RotationMatrixToEuler(const glm::mat3& R_c2w, sys::track::CoordinateSystem order) + { + float r, t, p; + switch (order) + { + default: + case sys::track::CoordinateSystem::ZYX: glm::extractEulerAngleZYX(glm::mat4(R_c2w), p, t, r); break; + case sys::track::CoordinateSystem::XYZ: glm::extractEulerAngleXYZ(glm::mat4(R_c2w), r, t, p); break; + case sys::track::CoordinateSystem::YXZ: glm::extractEulerAngleYXZ(glm::mat4(R_c2w), t, r, p); break; + case sys::track::CoordinateSystem::YZX: glm::extractEulerAngleYZX(glm::mat4(R_c2w), t, p, r); break; + case sys::track::CoordinateSystem::ZXY: glm::extractEulerAngleZXY(glm::mat4(R_c2w), p, r, t); break; + case sys::track::CoordinateSystem::XZY: glm::extractEulerAngleXZY(glm::mat4(R_c2w), r, p, t); break; + } + // Undo sign convention: r = -roll, t = -tilt, p = pan + return glm::degrees(glm::vec3(-r, -t, p)); + } + + // --- Execution --- + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + if (Frames.empty()) + { + sys::track::TTrack empty{}; + auto buf = nos::Buffer::From(empty); + SetPinValue(NOS_NAME("Track"), {.Data = buf.Data(), .Size = buf.Size()}); + return NOS_RESULT_SUCCESS; + } + + uint32_t frameIdx = FrameIndex < (uint32_t)Frames.size() ? FrameIndex : (uint32_t)Frames.size() - 1; + CurrentFrame = frameIdx; + + auto buf = nos::Buffer::From(Frames[frameIdx]); + SetPinValue(NOS_NAME("Track"), {.Data = buf.Data(), .Size = buf.Size()}); + UpdateFrameIndexPin(); + + return NOS_RESULT_SUCCESS; + } + + static nosResult GetFunctions(size_t* count, nosName* names, nosPfnNodeFunctionExecute* fns) + { + *count = 1; + if (!names || !fns) + return NOS_RESULT_SUCCESS; + + names[0] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_OpenFolder"); + fns[0] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (self->InputDir.empty()) + { + nosEngine.LogW("PlaybackTrackCOLMAP: Input directory not set"); + return NOS_RESULT_FAILED; + } + std::filesystem::path dir = nos::Utf8ToPath(self->InputDir); + if (!std::filesystem::exists(dir)) + { + nosEngine.LogW("PlaybackTrackCOLMAP: Directory does not exist: %s", self->InputDir.c_str()); + return NOS_RESULT_FAILED; + } + // TODO: Replace std::system with platform APIs (ShellExecuteW / posix_spawnp) +#if defined(_WIN32) + std::string cmd = "explorer \"" + nos::PathToUtf8(dir) + "\""; +#elif defined(__APPLE__) + std::string cmd = "open \"" + nos::PathToUtf8(dir) + "\""; +#else + std::string cmd = "xdg-open \"" + nos::PathToUtf8(dir) + "\""; +#endif + std::system(cmd.c_str()); + return NOS_RESULT_SUCCESS; + }; + + return NOS_RESULT_SUCCESS; + } +}; + +void RegisterPlaybackTrackCOLMAP(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("PlaybackTrackCOLMAP"), PlaybackTrackCOLMAPContext, fn); +} + +} // namespace nos::track diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp new file mode 100644 index 00000000..0284b2f6 --- /dev/null +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -0,0 +1,475 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include "nosSysTrack/Track_generated.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace nos::track +{ + +NOS_REGISTER_NAME(OutputDirectory); +NOS_REGISTER_NAME(ImageResolution); +NOS_REGISTER_NAME(CoordinateSystem); +NOS_REGISTER_NAME(Record); +NOS_REGISTER_NAME(FrameCount); +NOS_REGISTER_NAME(RecordingFrame); + +NOS_REGISTER_NAME(RecordTrackCOLMAP_Record); +NOS_REGISTER_NAME(RecordTrackCOLMAP_Stop); +NOS_REGISTER_NAME(RecordTrackCOLMAP_Save); +NOS_REGISTER_NAME(RecordTrackCOLMAP_Clear); +NOS_REGISTER_NAME(RecordTrackCOLMAP_OpenFolder); + +struct RecordedFrame +{ + glm::vec3 Location; + glm::vec3 Rotation; // Euler degrees (roll, tilt, pan) + float FOV; + glm::vec2 SensorSize; + float FocusDistance; + float PixelAspectRatio; + float K1; + float K2; +}; + +struct RecordTrackCOLMAPContext : NodeContext +{ + std::string OutputDir; + nosVec2u ImageResolution = {1920, 1080}; + sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; + bool Recording = false; + bool SyncingRecordPin = false; + std::string LastError; + std::vector Frames; + std::unordered_map FunctionIds; + + RecordTrackCOLMAPContext(nosFbNodePtr node) : NodeContext(node) + { + if (node->functions()) + { + for (auto* func : *node->functions()) + FunctionIds[nos::Name(func->class_name()->c_str())] = *func->id(); + } + + if (node->pins()) + { + for (auto* pin : *node->pins()) + { + auto name = nos::Name(pin->name()->c_str()); + if (flatbuffers::IsFieldPresent(pin, fb::Pin::VT_DATA)) + { + nosBuffer value = {.Data = (void*)pin->data()->data(), .Size = pin->data()->size()}; + OnPinValueChanged(name, *pin->id(), value); + } + } + } + UpdateFunctionOrphanStates(); + UpdateStatus(); + } + + void SetFunctionOrphanState(nos::Name funcName, fb::NodeOrphanStateType type) + { + auto it = FunctionIds.find(funcName); + if (it != FunctionIds.end()) + NodeContext::SetNodeOrphanState(it->second, type); + } + + void UpdateFunctionOrphanStates() + { + if (Recording) + { + SetFunctionOrphanState(NSN_RecordTrackCOLMAP_Record, fb::NodeOrphanStateType::ORPHAN); + SetFunctionOrphanState(NSN_RecordTrackCOLMAP_Stop, fb::NodeOrphanStateType::ACTIVE); + } + else + { + SetFunctionOrphanState(NSN_RecordTrackCOLMAP_Record, fb::NodeOrphanStateType::ACTIVE); + SetFunctionOrphanState(NSN_RecordTrackCOLMAP_Stop, fb::NodeOrphanStateType::ORPHAN); + } + } + + void SyncRecordPin(bool value) + { + SyncingRecordPin = true; + SetPinValue(NSN_Record, nosBuffer{.Data = &value, .Size = sizeof(value)}); + SyncingRecordPin = false; + } + + bool StartRecording() + { + std::string error; + if (!CanStartRecording(error)) + { + LastError = std::move(error); + UpdateStatus(); + return false; + } + LastError.clear(); + Frames.clear(); + Recording = true; + SyncRecordPin(true); + UpdateFrameCountPin(); + UpdateRecordingFramePin(); + UpdateFunctionOrphanStates(); + UpdateStatus(); + nosEngine.LogI("RecordTrackCOLMAP: Recording started"); + return true; + } + + void StopRecording() + { + Recording = false; + SyncRecordPin(false); + UpdateRecordingFramePin(); + UpdateFunctionOrphanStates(); + UpdateStatus(); + nosEngine.LogI("RecordTrackCOLMAP: Recording stopped (%zu frames in buffer)", Frames.size()); + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer val) override + { + if (pinName == NSN_OutputDirectory) + { + OutputDir = InterpretPinValue(val.Data); + LastError.clear(); + UpdateStatus(); + } + else if (pinName == NSN_ImageResolution) + ImageResolution = *(nosVec2u*)val.Data; + else if (pinName == NSN_CoordinateSystem) + CoordSys = *(sys::track::CoordinateSystem*)val.Data; + else if (pinName == NSN_Record) + { + if (SyncingRecordPin) + return; + bool newVal = *(bool*)val.Data; + if (newVal && !Recording) + StartRecording(); + else if (!newVal && Recording) + StopRecording(); + } + } + + bool CanStartRecording(std::string& outError) + { + if (OutputDir.empty()) + { + outError = "Set output directory"; + return false; + } + + std::filesystem::path outDir = nos::Utf8ToPath(OutputDir); + try + { + if (std::filesystem::exists(outDir) && !std::filesystem::is_empty(outDir)) + { + outError = "Target folder is not empty"; + return false; + } + } + catch (std::filesystem::filesystem_error& e) + { + nosEngine.LogE("RecordTrackCOLMAP: %s", e.what()); + outError = e.what(); + return false; + } + return true; + } + + void UpdateFrameCountPin() + { + uint32_t count = (uint32_t)Frames.size(); + SetPinValue(NSN_FrameCount, nosBuffer{.Data = &count, .Size = sizeof(count)}); + } + + void UpdateRecordingFramePin() + { + uint32_t frame = Recording ? (uint32_t)Frames.size() : 0; + SetPinValue(NSN_RecordingFrame, nosBuffer{.Data = &frame, .Size = sizeof(frame)}); + } + + void UpdateStatus() + { + if (!LastError.empty()) + SetNodeStatusMessage(LastError, fb::NodeStatusMessageType::FAILURE); + else if (OutputDir.empty()) + SetNodeStatusMessage("Set output directory", fb::NodeStatusMessageType::WARNING); + else if (Recording) + SetNodeStatusMessage("Recording (" + std::to_string(Frames.size()) + " frames)", fb::NodeStatusMessageType::INFO); + else if (!Frames.empty()) + SetNodeStatusMessage("Idle (" + std::to_string(Frames.size()) + " frames in buffer)", fb::NodeStatusMessageType::INFO); + else + SetNodeStatusMessage("Idle", fb::NodeStatusMessageType::INFO); + } + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + auto pins = GetPinValues(params); + + // Pass through Track input to output + nosBuffer trackBuf{}; + for (size_t i = 0; i < params->PinCount; ++i) + { + if (params->Pins[i].Name == NOS_NAME("InTrack")) + { + trackBuf = {.Data = (void*)params->Pins[i].Data->Data, .Size = params->Pins[i].Data->Size}; + break; + } + } + SetPinValue(NOS_NAME("OutTrack"), trackBuf); + + if (!Recording) + return NOS_RESULT_SUCCESS; + + auto* trackData = flatbuffers::GetRoot(trackBuf.Data); + if (!trackData) + return NOS_RESULT_SUCCESS; + + RecordedFrame frame{}; + if (auto* loc = trackData->location()) + frame.Location = {loc->x(), loc->y(), loc->z()}; + if (auto* rot = trackData->rotation()) + frame.Rotation = {rot->x(), rot->y(), rot->z()}; + frame.FOV = trackData->fov(); + if (auto* ss = trackData->sensor_size()) + frame.SensorSize = {ss->x(), ss->y()}; + frame.FocusDistance = trackData->focus_distance(); + frame.PixelAspectRatio = trackData->pixel_aspect_ratio(); + if (auto* ld = trackData->lens_distortion()) + { + frame.K1 = ld->k1k2().x(); + frame.K2 = ld->k1k2().y(); + } + Frames.push_back(frame); + + UpdateFrameCountPin(); + UpdateRecordingFramePin(); + UpdateStatus(); + + return NOS_RESULT_SUCCESS; + } + + void WriteFiles() + { + if (OutputDir.empty()) + { + nosEngine.LogE("RecordTrackCOLMAP: Output directory is empty"); + return; + } + if (Frames.empty()) + { + nosEngine.LogW("RecordTrackCOLMAP: No frames recorded"); + return; + } + + std::filesystem::path outDir = nos::Utf8ToPath(OutputDir); + try + { + if (!std::filesystem::exists(outDir)) + std::filesystem::create_directories(outDir); + } + catch (std::filesystem::filesystem_error& e) + { + nosEngine.LogE("RecordTrackCOLMAP: %s", e.what()); + return; + } + + WriteCamerasTxt(outDir); + WriteImagesTxt(outDir); + nosEngine.LogI("RecordTrackCOLMAP: Saved %zu frames to %s", Frames.size(), OutputDir.c_str()); + } + + float ComputeFocalLengthPixels(const RecordedFrame& frame) const + { + if (frame.FOV <= 0.0f) + return static_cast(ImageResolution.x); + float fovRad = glm::radians(frame.FOV); + return (ImageResolution.x * 0.5f) / std::tan(fovRad * 0.5f); + } + + void WriteCamerasTxt(const std::filesystem::path& outDir) + { + auto path = outDir / "cameras.txt"; + std::ofstream file(path); + if (!file.is_open()) + { + nosEngine.LogE("RecordTrackCOLMAP: Cannot open %s", nos::PathToUtf8(path).c_str()); + return; + } + + file << std::setprecision(12); + file << "# Camera list with one line of data per camera:\n"; + file << "# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n"; + file << "# Number of cameras: " << Frames.size() << "\n"; + + for (size_t i = 0; i < Frames.size(); ++i) + { + float fx = ComputeFocalLengthPixels(Frames[i]); + float fy = fx; + if (Frames[i].PixelAspectRatio > 0.0f) + fy = fx / Frames[i].PixelAspectRatio; + + float cx = ImageResolution.x * 0.5f; + float cy = ImageResolution.y * 0.5f; + + // OPENCV model: fx, fy, cx, cy, k1, k2, p1, p2 + float k1 = Frames[i].K1; + float k2 = Frames[i].K2; + + file << (i + 1) << " OPENCV " << ImageResolution.x << " " << ImageResolution.y << " " + << fx << " " << fy << " " << cx << " " << cy << " " + << k1 << " " << k2 << " 0 0\n"; + } + } + + static glm::mat3 EulerToRotationMatrix(glm::vec3 rot, sys::track::CoordinateSystem order) + { + // rot is (roll, tilt, pan) = (x, y, z) in radians + // Sign convention matches MakeRotation: negate roll (x) and tilt (y) + float r = -rot.x, t = -rot.y, p = rot.z; + switch (order) + { + default: + case sys::track::CoordinateSystem::ZYX: return glm::mat3(glm::eulerAngleZYX(p, t, r)); + case sys::track::CoordinateSystem::XYZ: return glm::mat3(glm::eulerAngleXYZ(r, t, p)); + case sys::track::CoordinateSystem::YXZ: return glm::mat3(glm::eulerAngleYXZ(t, r, p)); + case sys::track::CoordinateSystem::YZX: return glm::mat3(glm::eulerAngleYZX(t, p, r)); + case sys::track::CoordinateSystem::ZXY: return glm::mat3(glm::eulerAngleZXY(p, r, t)); + case sys::track::CoordinateSystem::XZY: return glm::mat3(glm::eulerAngleXZY(r, p, t)); + } + } + + void WriteImagesTxt(const std::filesystem::path& outDir) + { + auto path = outDir / "images.txt"; + std::ofstream file(path); + if (!file.is_open()) + { + nosEngine.LogE("RecordTrackCOLMAP: Cannot open %s", nos::PathToUtf8(path).c_str()); + return; + } + + file << std::setprecision(12); + file << "# Image list with two lines of data per image:\n"; + file << "# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n"; + file << "# POINTS2D[] as (X, Y, POINT3D_ID)\n"; + file << "# Number of images: " << Frames.size() << "\n"; + + for (size_t i = 0; i < Frames.size(); ++i) + { + auto& frame = Frames[i]; + + // Convert Euler angles to rotation matrix + // Sign convention matches MakeRotation: negate roll (x) and tilt (y) + glm::vec3 rot = glm::radians(frame.Rotation); + glm::mat3 R_c2w = EulerToRotationMatrix(rot, CoordSys); + + // COLMAP expects world-to-camera rotation + glm::mat3 R_w2c = glm::transpose(R_c2w); + glm::quat q_w2c = glm::quat_cast(R_w2c); + + // COLMAP translation: t = -R * C (camera center in world coords) + glm::vec3 t = -R_w2c * frame.Location; + + // IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME + file << (i + 1) << " " + << q_w2c.w << " " << q_w2c.x << " " << q_w2c.y << " " << q_w2c.z << " " + << t.x << " " << t.y << " " << t.z << " " + << (i + 1) << " " + << "frame_" << std::setfill('0') << std::setw(6) << i << ".png\n"; + // Empty points line (required by COLMAP format) + file << "\n"; + } + } + + // TODO: Replace std::system with platform APIs (ShellExecuteW / posix_spawnp) to avoid shell injection via crafted paths + static void OpenFolderInExplorer(const std::filesystem::path& folder) + { +#if defined(_WIN32) + std::string cmd = "explorer \"" + nos::PathToUtf8(folder) + "\""; +#elif defined(__APPLE__) + std::string cmd = "open \"" + nos::PathToUtf8(folder) + "\""; +#else + std::string cmd = "xdg-open \"" + nos::PathToUtf8(folder) + "\""; +#endif + std::system(cmd.c_str()); + } + + static nosResult GetFunctions(size_t* count, nosName* names, nosPfnNodeFunctionExecute* fns) + { + *count = 5; + if (!names || !fns) + return NOS_RESULT_SUCCESS; + + names[0] = NOS_NAME_STATIC("RecordTrackCOLMAP_Record"); + fns[0] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (self->Recording) + return NOS_RESULT_SUCCESS; + self->StartRecording(); + return NOS_RESULT_SUCCESS; + }; + + names[1] = NOS_NAME_STATIC("RecordTrackCOLMAP_Stop"); + fns[1] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (!self->Recording) + return NOS_RESULT_SUCCESS; + self->StopRecording(); + return NOS_RESULT_SUCCESS; + }; + + names[2] = NOS_NAME_STATIC("RecordTrackCOLMAP_Save"); + fns[2] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + self->WriteFiles(); + return NOS_RESULT_SUCCESS; + }; + + names[3] = NOS_NAME_STATIC("RecordTrackCOLMAP_Clear"); + fns[3] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + self->Frames.clear(); + self->UpdateFrameCountPin(); + self->UpdateStatus(); + nosEngine.LogI("RecordTrackCOLMAP: Buffer cleared"); + return NOS_RESULT_SUCCESS; + }; + + names[4] = NOS_NAME_STATIC("RecordTrackCOLMAP_OpenFolder"); + fns[4] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (self->OutputDir.empty()) + { + nosEngine.LogW("RecordTrackCOLMAP: Output directory not set"); + return NOS_RESULT_FAILED; + } + std::filesystem::path outDir = nos::Utf8ToPath(self->OutputDir); + if (!std::filesystem::exists(outDir)) + { + nosEngine.LogW("RecordTrackCOLMAP: Directory does not exist: %s", self->OutputDir.c_str()); + return NOS_RESULT_FAILED; + } + OpenFolderInExplorer(outDir); + return NOS_RESULT_SUCCESS; + }; + + return NOS_RESULT_SUCCESS; + } +}; + +void RegisterRecordTrackCOLMAP(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("RecordTrackCOLMAP"), RecordTrackCOLMAPContext, fn); +} + +} // namespace nos::track diff --git a/Plugins/nosTrack/Source/TrackMain.cpp b/Plugins/nosTrack/Source/TrackMain.cpp index c330165d..acca3be8 100644 --- a/Plugins/nosTrack/Source/TrackMain.cpp +++ b/Plugins/nosTrack/Source/TrackMain.cpp @@ -15,12 +15,16 @@ enum TrackNode : int FreeD, UserTrack, AddTrack, + RecordTrackCOLMAP, + PlaybackTrackCOLMAP, Count }; void RegisterFreeDNode(nosNodeFunctions* functions); void RegisterController(nosNodeFunctions* functions); void RegisterAddTrack(nosNodeFunctions*); +void RegisterRecordTrackCOLMAP(nosNodeFunctions*); +void RegisterPlaybackTrackCOLMAP(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -40,7 +44,13 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou RegisterController(node); break; case TrackNode::AddTrack: - RegisterAddTrack(node); + RegisterAddTrack(node); + break; + case TrackNode::RecordTrackCOLMAP: + RegisterRecordTrackCOLMAP(node); + break; + case TrackNode::PlaybackTrackCOLMAP: + RegisterPlaybackTrackCOLMAP(node); break; } } diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index 861423c0..97473fef 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -2,21 +2,23 @@ "info": { "id": { "name": "nos.track", - "version": "1.10.0" + "version": "1.11.0" }, "display_name": "Track", "category": "Virtual Studio", "dependencies": [ { "name": "nos.sys.track", - "version": "1.0" + "version": "1.1" } ] }, "node_definitions": [ "Config/FreeD.nosdef", "Config/UserTrack.nosdef", - "Config/AddTrack.nosdef" + "Config/AddTrack.nosdef", + "Config/RecordTrackCOLMAP.nosdef", + "Config/PlaybackTrackCOLMAP.nosdef" ], "defaults": [ "Config/Defaults.json" diff --git a/Plugins/nosUtilities/Config/MultiLiveOut.nosdef b/Plugins/nosUtilities/Config/MultiLiveOut.nosdef new file mode 100644 index 00000000..36997973 --- /dev/null +++ b/Plugins/nosUtilities/Config/MultiLiveOut.nosdef @@ -0,0 +1,30 @@ +{ + "nodes": [ + { + "class_name": "MultiLiveOut", + "menu_info": { + "category": "Scheduling", + "display_name": "Multi Live Out" + }, + "node": { + "class_name": "MultiLiveOut", + "contents_type": "Job", + "pins": [ + { + "name": "Input_0", + "type_name": "nos.Generic", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Output_0", + "type_name": "nos.Generic", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "live": true + } + ] + } + } + ] +} diff --git a/Plugins/nosUtilities/Source/MultiLiveOut.cpp b/Plugins/nosUtilities/Source/MultiLiveOut.cpp new file mode 100644 index 00000000..c4d08d88 --- /dev/null +++ b/Plugins/nosUtilities/Source/MultiLiveOut.cpp @@ -0,0 +1,189 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include + +namespace nos::utilities +{ + +struct MultiLiveOutNode : NodeContext +{ + MultiLiveOutNode(nosFbNodePtr node) : NodeContext(node) + { + for (auto* pin : *node->pins()) + { + SetPinOrphanState(*pin->id(), nos::fb::PinOrphanStateType::ACTIVE); + auto index = GetPinIndex(pin->name()->string_view()); + if (!index) + { + nosEngine.LogE("Failed to parse index from pin name: %s", pin->name()->c_str()); + continue; + } + if (pin->show_as() == nosFbShowAs::OUTPUT_PIN) + IndexToPairs[*index].second = uuid(*pin->id()); + else + IndexToPairs[*index].first = uuid(*pin->id()); + } + } + + void OnNodeUpdated(nosNodeUpdate const* update) override + { + if (update->Type == NOS_NODE_UPDATE_PIN_CREATED) + { + auto* pin = update->PinCreated; + auto index = GetPinIndex(pin->name()->string_view()); + if (!index) + return; + if (pin->show_as() == nosFbShowAs::OUTPUT_PIN) + IndexToPairs[*index].second = uuid(*pin->id()); + else + IndexToPairs[*index].first = uuid(*pin->id()); + } + else if (update->Type == NOS_NODE_UPDATE_PIN_DELETED) + { + for (auto it = IndexToPairs.begin(); it != IndexToPairs.end(); ++it) + { + if (it->second.first == update->PinDeleted || it->second.second == update->PinDeleted) + { + IndexToPairs.erase(it); + break; + } + } + } + } + + void OnMenuRequested(nosContextMenuRequestPtr request) override + { + flatbuffers::FlatBufferBuilder fbb; + std::vector> items; + if (*request->item_id() == NodeId) + items.push_back(nos::CreateContextMenuItemDirect(fbb, "Add New Pair", 1)); + else + { + auto* pin = GetPin(*request->item_id()); + if (!pin) + return; + if (pin->Name == NOS_NAME("Input_0") || pin->Name == NOS_NAME("Output_0")) + return; + items.push_back(nos::CreateContextMenuItemDirect(fbb, "Remove Pair", 1)); + } + HandleEvent(CreateAppEvent( + fbb, CreateAppContextMenuUpdate( + fbb, request->item_id(), request->pos(), request->instigator(), fbb.CreateVector(items)))); + } + + void OnMenuCommand(uuid const& itemID, uint32_t cmd) override + { + flatbuffers::FlatBufferBuilder fbb; + if (itemID == NodeId) + { + int index = 0; + for (; index < (int)IndexToPairs.size(); index++) + { + if (!IndexToPairs.contains(index)) + break; + } + fb::TPin outPin; + outPin.id = uuid(nosEngine.GenerateID()); + outPin.name = "Output_" + std::to_string(index); + outPin.type_name = NOS_NAME("nos.Generic"); + outPin.live = true; + outPin.show_as = fb::ShowAs::OUTPUT_PIN; + outPin.can_show_as = fb::CanShowAs::OUTPUT_PIN_ONLY; + + fb::TPin inPin; + inPin.id = uuid(nosEngine.GenerateID()); + inPin.name = "Input_" + std::to_string(index); + inPin.type_name = NOS_NAME("nos.Generic"); + inPin.show_as = fb::ShowAs::INPUT_PIN; + inPin.can_show_as = fb::CanShowAs::INPUT_PIN_ONLY; + + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_add.emplace_back(std::make_unique(std::move(outPin))); + update.pins_to_add.emplace_back(std::make_unique(std::move(inPin))); + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + IndexToPairs[index] = {uuid(inPin.id), uuid(outPin.id)}; + } + else + { + auto* pin = GetPin(itemID); + if (!pin) + return; + auto index = GetPinIndex(pin->Name.AsString()); + if (!index) + { + nosEngine.LogE("Failed to parse index from pin name: %s", pin->Name.AsCStr()); + return; + } + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_delete = {IndexToPairs[*index].first, IndexToPairs[*index].second}; + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + IndexToPairs.erase(*index); + } + } + + nosResult OnResolvePinDataTypes(nosResolvePinDataTypesParams* params) override + { + auto pinName = nos::Name(params->InstigatorPinName).AsString(); + auto index = GetPinIndex(pinName); + if (!index.has_value()) + { + strcpy(params->OutErrorMessage, "Failed to parse pin index from pin name."); + return NOS_RESULT_FAILED; + } + auto const& [firstId, secondId] = IndexToPairs[*index]; + for (size_t i = 0; i < params->PinCount; i++) + { + auto& pin = params->Pins[i]; + if (pin.Id == firstId || pin.Id == secondId) + pin.OutResolvedTypeName = params->IncomingTypeName; + else + pin.OutResolvedTypeName = NOS_NAME("nos.Generic"); + } + return NOS_RESULT_SUCCESS; + } + + std::optional GetPinIndex(std::string_view pinName) const + { + auto indexPos = pinName.find_last_of('_'); + if (indexPos == std::string::npos) + return std::nullopt; + try + { + return std::stoi(std::string(pinName.substr(indexPos + 1))); + } + catch (...) + { + nosEngine.LogE("Failed to parse index from pin name: %s", std::string(pinName).c_str()); + return std::nullopt; + } + } + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + for (auto const& [_, idPair] : IndexToPairs) + { + for (size_t i = 0; i < params->PinCount; ++i) + { + auto& pin = params->Pins[i]; + if (pin.Id == idPair.first && pin.Data) + { + nosEngine.SetPinValue(idPair.second, *pin.Data); + break; + } + } + } + return NOS_RESULT_SUCCESS; + } + + std::unordered_map> IndexToPairs; +}; + +nosResult RegisterMultiLiveOut(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("MultiLiveOut"), MultiLiveOutNode, fn) + return NOS_RESULT_SUCCESS; +} + +} // namespace nos::utilities diff --git a/Plugins/nosUtilities/Source/UtilitiesMain.cpp b/Plugins/nosUtilities/Source/UtilitiesMain.cpp index c3d3e24a..b3fcc186 100644 --- a/Plugins/nosUtilities/Source/UtilitiesMain.cpp +++ b/Plugins/nosUtilities/Source/UtilitiesMain.cpp @@ -57,6 +57,7 @@ enum Utilities : int GridOutputLayout, LoadCubeLUT, RepeatingJunction, + MultiLiveOut, Count }; @@ -93,6 +94,7 @@ nosResult RegisterFreeOutputLayout(nosNodeFunctions*); nosResult RegisterGridOutputLayout(nosNodeFunctions*); nosResult RegisterLoadCubeLUT(nosNodeFunctions*); nosResult RegisterRepeatingJunction(nosNodeFunctions*); +nosResult RegisterMultiLiveOut(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -145,6 +147,7 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou GEN_CASE_NODE(GridOutputLayout) GEN_CASE_NODE(LoadCubeLUT) GEN_CASE_NODE(RepeatingJunction) + GEN_CASE_NODE(MultiLiveOut) } } return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosUtilities/Utilities.noscfg b/Plugins/nosUtilities/Utilities.noscfg index 79883ae9..0320cd0d 100644 --- a/Plugins/nosUtilities/Utilities.noscfg +++ b/Plugins/nosUtilities/Utilities.noscfg @@ -2,7 +2,7 @@ "info": { "id": { "name": "nos.utilities", - "version": "3.14.8" + "version": "3.15.0" }, "description": "Various utility nodes.", "display_name": "Utilities", @@ -63,7 +63,8 @@ "Config/CalculateDispatchSize.nosdef", "Config/YADIF.nosdef", "Config/YADIFWithAutoDispatchSize.nosdef", - "Config/RepeatingJunction.nosdef" + "Config/RepeatingJunction.nosdef", + "Config/MultiLiveOut.nosdef" ], "custom_types": [ "Config/Merge.fbs", diff --git a/Subsystems/nosTrackSubsystem/Config/Track.fbs b/Subsystems/nosTrackSubsystem/Config/Track.fbs index e1dcce23..75e6e1ff 100644 --- a/Subsystems/nosTrackSubsystem/Config/Track.fbs +++ b/Subsystems/nosTrackSubsystem/Config/Track.fbs @@ -42,3 +42,12 @@ enum RotationSystem : uint { RPT = 4, PRT = 5, } + +enum EulerOrder : uint { + ZYX = 0, + XYZ = 1, + YXZ = 2, + YZX = 3, + ZXY = 4, + XZY = 5, +} diff --git a/Subsystems/nosTrackSubsystem/nosTrackSubsystem.nossys b/Subsystems/nosTrackSubsystem/nosTrackSubsystem.nossys index d2f6b9cb..625fd3fe 100644 --- a/Subsystems/nosTrackSubsystem/nosTrackSubsystem.nossys +++ b/Subsystems/nosTrackSubsystem/nosTrackSubsystem.nossys @@ -2,7 +2,7 @@ "info": { "id": { "name": "nos.sys.track", - "version": "1.0.0" + "version": "1.1.0" }, "display_name": "Track Subsystem", "dependencies": [