From 2f5618ebb6fd0997e8f7d03e00a53f8fdb045330 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 31 Mar 2026 18:37:49 +0300 Subject: [PATCH 01/30] Add Record Track (COLMAP) node to nosTrack plugin New node that records camera tracking data per frame and exports COLMAP-format files (cameras.txt + images.txt). - RecordTrackCOLMAP.cpp: Node implementation with Record/Stop/Save/Clear/ Open Folder functions. Captures position, rotation, FOV, sensor size, and lens distortion each frame. Exports OPENCV camera model intrinsics and world-to-camera extrinsics. - RecordTrackCOLMAP.nosdef: Node definition with Track input/output, output directory, image resolution, euler order, record toggle, and frame count pins. - Track.fbs: Added EulerOrder enum (ZYX, XYZ, YXZ, YZX, ZXY, XZY) for configurable euler angle rotation order in COLMAP export. - TrackMain.cpp: Registered RecordTrackCOLMAP in TrackNode enum and ExportNodeFunctions switch. - Track.noscfg: Bumped plugin version to 1.10.0, added nosdef entry. Review fixes applied: - Pin buffer size looked up by name instead of hardcoded index - Null checks on Track flatbuffer fields to prevent crashes - Euler convention matches MakeRotation (eulerAngleZYX with sign negation) - Float output precision set to 12 digits for camera parameters - macOS support added to Open Folder Co-Authored-By: Claude Opus 4.6 (1M context) # Conflicts: # Subsystems/nosTrackSubsystem/Config/Track.fbs --- Plugins/nosTrack/CHANGES.md | 57 +++ .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 114 +++++ Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 467 ++++++++++++++++++ Plugins/nosTrack/Source/TrackMain.cpp | 7 +- Plugins/nosTrack/Track.noscfg | 3 +- Subsystems/nosTrackSubsystem/Config/Track.fbs | 9 + 6 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 Plugins/nosTrack/CHANGES.md create mode 100644 Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef create mode 100644 Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp 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/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef new file mode 100644 index 00000000..ad4288b7 --- /dev/null +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -0,0 +1,114 @@ +{ + "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", + "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": "Track", + "type_name": "nos.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": "TrackOut", + "display_name": "Track Out", + "type_name": "nos.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": "EulerOrder", + "display_name": "Euler Order", + "type_name": "nos.track.EulerOrder", + "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": "FrameCount", + "display_name": "Frame Count", + "type_name": "uint", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": 0, + "description": "Number of frames recorded in the current session." + } + ], + "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/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp new file mode 100644 index 00000000..9b2751cd --- /dev/null +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -0,0 +1,467 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include "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(EulerOrder); +NOS_REGISTER_NAME(Record); +NOS_REGISTER_NAME(FrameCount); + +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}; + track::EulerOrder EulerOrd = track::EulerOrder::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; + nosEngine.SetPinValueByName(NodeId, 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(); + UpdateFunctionOrphanStates(); + UpdateStatus(); + nosEngine.LogI("RecordTrackCOLMAP: Recording started"); + return true; + } + + void StopRecording() + { + Recording = false; + SyncRecordPin(false); + 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_EulerOrder) + EulerOrd = *(track::EulerOrder*)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(); + nosEngine.SetPinValueByName(NodeId, NSN_FrameCount, nosBuffer{.Data = &count, .Size = sizeof(count)}); + } + + 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); + auto ids = GetPinIds(params); + + // Pass through Track input to output + auto trackPinData = pins[NOS_NAME("Track")]; + size_t trackDataSize = 0; + for (size_t i = 0; i < params->PinCount; ++i) + { + if (params->Pins[i].Name == NOS_NAME("Track")) + { + trackDataSize = params->Pins[i].Data->Size; + break; + } + } + nosEngine.SetPinValue(ids[NOS_NAME("TrackOut")], {.Data = trackPinData, .Size = trackDataSize}); + + if (!Recording) + return NOS_RESULT_SUCCESS; + + auto* trackData = flatbuffers::GetRoot(trackPinData); + 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(); + 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, track::EulerOrder 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 track::EulerOrder::ZYX: return glm::mat3(glm::eulerAngleZYX(p, t, r)); + case track::EulerOrder::XYZ: return glm::mat3(glm::eulerAngleXYZ(r, t, p)); + case track::EulerOrder::YXZ: return glm::mat3(glm::eulerAngleYXZ(t, r, p)); + case track::EulerOrder::YZX: return glm::mat3(glm::eulerAngleYZX(t, p, r)); + case track::EulerOrder::ZXY: return glm::mat3(glm::eulerAngleZXY(p, r, t)); + case track::EulerOrder::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, EulerOrd); + + // 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..628abd33 100644 --- a/Plugins/nosTrack/Source/TrackMain.cpp +++ b/Plugins/nosTrack/Source/TrackMain.cpp @@ -15,12 +15,14 @@ enum TrackNode : int FreeD, UserTrack, AddTrack, + RecordTrackCOLMAP, Count }; void RegisterFreeDNode(nosNodeFunctions* functions); void RegisterController(nosNodeFunctions* functions); void RegisterAddTrack(nosNodeFunctions*); +void RegisterRecordTrackCOLMAP(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -40,7 +42,10 @@ 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; } } diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index 861423c0..d6310c82 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -16,7 +16,8 @@ "node_definitions": [ "Config/FreeD.nosdef", "Config/UserTrack.nosdef", - "Config/AddTrack.nosdef" + "Config/AddTrack.nosdef", + "Config/RecordTrackCOLMAP.nosdef" ], "defaults": [ "Config/Defaults.json" 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, +} From 803bab585622b3a9508e0e6b6c205e94ada16365 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 1 Apr 2026 15:38:38 +0300 Subject: [PATCH 02/30] Add Playback Track (COLMAP) node, rename pins, use NodeContext helpers New PlaybackTrackCOLMAP node loads cameras.txt + images.txt and outputs Track data. Two modes via PlaybackMode enum: - Sequential: Play/Stop auto-advance frames each execution - Manual: frame index input pin controls which frame to output Pins and functions are orphaned based on mode. Also: - Added PlaybackMode enum to Track.fbs - Renamed Record node pins to InTrack/OutTrack with "Track" display name - Renamed Playback frame pins to InFrameIndex/OutFrameIndex - Replaced nosEngine.SetPinValueByName with NodeContext::SetPinValue Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Config/PlaybackTrackCOLMAP.nosdef | 109 ++++ .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 7 +- .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 486 ++++++++++++++++++ Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 16 +- Plugins/nosTrack/Source/TrackMain.cpp | 5 + Plugins/nosTrack/Track.noscfg | 3 +- Subsystems/nosTrackSubsystem/Config/Track.fbs | 5 + 7 files changed, 618 insertions(+), 13 deletions(-) create mode 100644 Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef create mode 100644 Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef new file mode 100644 index 00000000..d0f260c5 --- /dev/null +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -0,0 +1,109 @@ +{ + "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. Sequential mode auto-advances each frame with Play/Stop control. Manual mode outputs the frame at a given 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": "EulerOrder", + "display_name": "Euler Order", + "type_name": "nos.track.EulerOrder", + "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": "Mode", + "type_name": "nos.track.PlaybackMode", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "Sequential", + "description": "Sequential: auto-advances one frame per execution with Play/Stop control. Manual: outputs the frame at the given Frame Input index." + }, + { + "name": "Loop", + "type_name": "bool", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": true, + "description": "Loop playback to the beginning when the last frame is reached." + }, + { + "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 (Manual mode only)." + }, + { + "name": "Track", + "type_name": "nos.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_Play", + "display_name": "Play", + "contents_type": "Job", + "pins": [] + }, + { + "class_name": "PlaybackTrackCOLMAP_Stop", + "display_name": "Stop", + "contents_type": "Job", + "pins": [] + }, + { + "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 index ad4288b7..5a17b344 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -14,15 +14,16 @@ "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": "Track", + "name": "InTrack", + "display_name": "Track", "type_name": "nos.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": "TrackOut", - "display_name": "Track Out", + "name": "OutTrack", + "display_name": "Track", "type_name": "nos.track.Track", "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp new file mode 100644 index 00000000..d57b5b0c --- /dev/null +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -0,0 +1,486 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include "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_EulerOrder, "EulerOrder"); +NOS_REGISTER_NAME_SPACED(Playback_Mode, "Mode"); +NOS_REGISTER_NAME_SPACED(Playback_Loop, "Loop"); +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_Play); +NOS_REGISTER_NAME(PlaybackTrackCOLMAP_Stop); +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; + track::EulerOrder EulerOrd = track::EulerOrder::ZYX; + track::PlaybackMode Mode = track::PlaybackMode::Sequential; + bool Loop = true; + bool Playing = false; + uint32_t ManualFrame = 0; + std::string LastError; + std::vector Frames; + uint32_t CurrentFrame = 0; + std::unordered_map FunctionIds; + std::unordered_map PinIds; + + PlaybackTrackCOLMAPContext(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()); + PinIds[name] = *pin->id(); + if (flatbuffers::IsFieldPresent(pin, fb::Pin::VT_DATA)) + { + nosBuffer value = {.Data = (void*)pin->data()->data(), .Size = pin->data()->size()}; + OnPinValueChanged(name, *pin->id(), value); + } + } + } + UpdateOrphanStates(); + UpdateStatus(); + } + + void SetFunctionOrphanState(nos::Name funcName, fb::NodeOrphanStateType type) + { + auto it = FunctionIds.find(funcName); + if (it != FunctionIds.end()) + NodeContext::SetNodeOrphanState(it->second, type); + } + + void SetPinOrphanState(nos::Name pinName, fb::PinOrphanStateType type) + { + auto it = PinIds.find(pinName); + if (it != PinIds.end()) + NodeContext::SetPinOrphanState(it->second, type); + } + + void UpdateOrphanStates() + { + bool sequential = Mode == track::PlaybackMode::Sequential; + + // Sequential: Play/Stop active, Load/FrameInput orphaned + // Manual: Load/FrameInput active, Play/Stop orphaned + if (sequential) + { + SetPinOrphanState(NSN_Playback_InFrameIndex, fb::PinOrphanStateType::ORPHAN); + + if (Playing) + { + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ORPHAN); + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ACTIVE); + } + else + { + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ACTIVE); + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ORPHAN); + } + } + else + { + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ORPHAN); + SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ORPHAN); + SetPinOrphanState(NSN_Playback_InFrameIndex, fb::PinOrphanStateType::ACTIVE); + } + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer val) override + { + if (pinName == NSN_Playback_InputDirectory) + { + InputDir = InterpretPinValue(val.Data); + LastError.clear(); + if (Mode == track::PlaybackMode::Manual && !InputDir.empty()) + LoadFromDirectory(); + else + UpdateStatus(); + } + else if (pinName == NSN_Playback_EulerOrder) + EulerOrd = *(track::EulerOrder*)val.Data; + else if (pinName == NSN_Playback_Mode) + { + auto newMode = *(track::PlaybackMode*)val.Data; + if (newMode != Mode) + { + Mode = newMode; + Playing = false; + UpdateOrphanStates(); + UpdateStatus(); + } + } + else if (pinName == NSN_Playback_Loop) + Loop = *(bool*)val.Data; + else if (pinName == NSN_Playback_InFrameIndex) + ManualFrame = *(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 if (Mode == track::PlaybackMode::Sequential && Playing) + SetNodeStatusMessage("Playing (" + std::to_string(CurrentFrame + 1) + "/" + std::to_string(Frames.size()) + ")", fb::NodeStatusMessageType::INFO); + 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) + { + 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, EulerOrd); + 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, track::EulerOrder order) + { + float r, t, p; + switch (order) + { + default: + case track::EulerOrder::ZYX: glm::extractEulerAngleZYX(glm::mat4(R_c2w), p, t, r); break; + case track::EulerOrder::XYZ: glm::extractEulerAngleXYZ(glm::mat4(R_c2w), r, t, p); break; + case track::EulerOrder::YXZ: glm::extractEulerAngleYXZ(glm::mat4(R_c2w), t, r, p); break; + case track::EulerOrder::YZX: glm::extractEulerAngleYZX(glm::mat4(R_c2w), t, p, r); break; + case track::EulerOrder::ZXY: glm::extractEulerAngleZXY(glm::mat4(R_c2w), p, r, t); break; + case track::EulerOrder::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()) + { + 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 = 0; + if (Mode == track::PlaybackMode::Sequential) + { + if (!Playing) + { + frameIdx = CurrentFrame; + } + else + { + frameIdx = CurrentFrame; + uint32_t next = CurrentFrame + 1; + if (next >= (uint32_t)Frames.size()) + next = Loop ? 0 : (uint32_t)Frames.size() - 1; + CurrentFrame = next; + } + } + else + { + frameIdx = ManualFrame < (uint32_t)Frames.size() ? ManualFrame : (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(); + + if (Mode == track::PlaybackMode::Sequential && Playing) + UpdateStatus(); + + return NOS_RESULT_SUCCESS; + } + + static nosResult GetFunctions(size_t* count, nosName* names, nosPfnNodeFunctionExecute* fns) + { + *count = 3; + if (!names || !fns) + return NOS_RESULT_SUCCESS; + + names[0] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_Play"); + fns[0] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (self->Playing) + return NOS_RESULT_SUCCESS; + if (self->Frames.empty()) + self->LoadFromDirectory(); + if (self->Frames.empty()) + return NOS_RESULT_SUCCESS; + self->Playing = true; + self->CurrentFrame = 0; + self->UpdateOrphanStates(); + self->UpdateStatus(); + nosEngine.LogI("PlaybackTrackCOLMAP: Playing (%zu frames)", self->Frames.size()); + return NOS_RESULT_SUCCESS; + }; + + names[1] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_Stop"); + fns[1] = [](void* ctx, nosFunctionExecuteParams*) { + auto* self = static_cast(ctx); + if (!self->Playing) + return NOS_RESULT_SUCCESS; + self->Playing = false; + self->UpdateOrphanStates(); + self->UpdateStatus(); + nosEngine.LogI("PlaybackTrackCOLMAP: Stopped at frame %u", self->CurrentFrame); + return NOS_RESULT_SUCCESS; + }; + + names[2] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_OpenFolder"); + fns[3] = [](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 index 9b2751cd..e7e91b55 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -99,7 +99,7 @@ struct RecordTrackCOLMAPContext : NodeContext void SyncRecordPin(bool value) { SyncingRecordPin = true; - nosEngine.SetPinValueByName(NodeId, NSN_Record, nosBuffer{.Data = &value, .Size = sizeof(value)}); + SetPinValue(NSN_Record, nosBuffer{.Data = &value, .Size = sizeof(value)}); SyncingRecordPin = false; } @@ -185,7 +185,7 @@ struct RecordTrackCOLMAPContext : NodeContext void UpdateFrameCountPin() { uint32_t count = (uint32_t)Frames.size(); - nosEngine.SetPinValueByName(NodeId, NSN_FrameCount, nosBuffer{.Data = &count, .Size = sizeof(count)}); + SetPinValue(NSN_FrameCount, nosBuffer{.Data = &count, .Size = sizeof(count)}); } void UpdateStatus() @@ -205,25 +205,23 @@ struct RecordTrackCOLMAPContext : NodeContext nosResult ExecuteNode(nosNodeExecuteParams* params) override { auto pins = GetPinValues(params); - auto ids = GetPinIds(params); // Pass through Track input to output - auto trackPinData = pins[NOS_NAME("Track")]; - size_t trackDataSize = 0; + nosBuffer trackBuf{}; for (size_t i = 0; i < params->PinCount; ++i) { - if (params->Pins[i].Name == NOS_NAME("Track")) + if (params->Pins[i].Name == NOS_NAME("InTrack")) { - trackDataSize = params->Pins[i].Data->Size; + trackBuf = {.Data = (void*)params->Pins[i].Data->Data, .Size = params->Pins[i].Data->Size}; break; } } - nosEngine.SetPinValue(ids[NOS_NAME("TrackOut")], {.Data = trackPinData, .Size = trackDataSize}); + SetPinValue(NOS_NAME("OutTrack"), trackBuf); if (!Recording) return NOS_RESULT_SUCCESS; - auto* trackData = flatbuffers::GetRoot(trackPinData); + auto* trackData = flatbuffers::GetRoot(trackBuf.Data); if (!trackData) return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosTrack/Source/TrackMain.cpp b/Plugins/nosTrack/Source/TrackMain.cpp index 628abd33..acca3be8 100644 --- a/Plugins/nosTrack/Source/TrackMain.cpp +++ b/Plugins/nosTrack/Source/TrackMain.cpp @@ -16,6 +16,7 @@ enum TrackNode : int UserTrack, AddTrack, RecordTrackCOLMAP, + PlaybackTrackCOLMAP, Count }; @@ -23,6 +24,7 @@ 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) { @@ -47,6 +49,9 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou case TrackNode::RecordTrackCOLMAP: RegisterRecordTrackCOLMAP(node); break; + case TrackNode::PlaybackTrackCOLMAP: + RegisterPlaybackTrackCOLMAP(node); + break; } } return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index d6310c82..c8a13052 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -17,7 +17,8 @@ "Config/FreeD.nosdef", "Config/UserTrack.nosdef", "Config/AddTrack.nosdef", - "Config/RecordTrackCOLMAP.nosdef" + "Config/RecordTrackCOLMAP.nosdef", + "Config/PlaybackTrackCOLMAP.nosdef" ], "defaults": [ "Config/Defaults.json" diff --git a/Subsystems/nosTrackSubsystem/Config/Track.fbs b/Subsystems/nosTrackSubsystem/Config/Track.fbs index 75e6e1ff..2da4998e 100644 --- a/Subsystems/nosTrackSubsystem/Config/Track.fbs +++ b/Subsystems/nosTrackSubsystem/Config/Track.fbs @@ -51,3 +51,8 @@ enum EulerOrder : uint { ZXY = 4, XZY = 5, } + +enum PlaybackMode : uint { + Sequential = 0, + Manual = 1, +} From e3af9029ed9502e1a48173018a93dbbb8e30b487 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Fri, 3 Apr 2026 13:46:12 +0300 Subject: [PATCH 03/30] Backport MultiLiveOut node to 1.3 --- .../nosUtilities/Config/MultiLiveOut.nosdef | 30 +++ Plugins/nosUtilities/Source/MultiLiveOut.cpp | 189 ++++++++++++++++++ Plugins/nosUtilities/Source/UtilitiesMain.cpp | 3 + Plugins/nosUtilities/Utilities.noscfg | 5 +- 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 Plugins/nosUtilities/Config/MultiLiveOut.nosdef create mode 100644 Plugins/nosUtilities/Source/MultiLiveOut.cpp 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", From a797fb84ecc2b0bee3b66e40554033ae24695da2 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Fri, 3 Apr 2026 13:46:58 +0300 Subject: [PATCH 04/30] Add RecordingFrame for track record node --- Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef | 11 ++++++++++- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 5a17b344..10e5c006 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -67,6 +67,15 @@ "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", @@ -74,7 +83,7 @@ "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, - "description": "Number of frames recorded in the current session." + "description": "Number of frames in the buffer." } ], "functions": [ diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index e7e91b55..8e269a0a 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -21,6 +21,7 @@ NOS_REGISTER_NAME(ImageResolution); NOS_REGISTER_NAME(EulerOrder); NOS_REGISTER_NAME(Record); NOS_REGISTER_NAME(FrameCount); +NOS_REGISTER_NAME(RecordingFrame); NOS_REGISTER_NAME(RecordTrackCOLMAP_Record); NOS_REGISTER_NAME(RecordTrackCOLMAP_Stop); @@ -117,6 +118,7 @@ struct RecordTrackCOLMAPContext : NodeContext Recording = true; SyncRecordPin(true); UpdateFrameCountPin(); + UpdateRecordingFramePin(); UpdateFunctionOrphanStates(); UpdateStatus(); nosEngine.LogI("RecordTrackCOLMAP: Recording started"); @@ -127,6 +129,7 @@ struct RecordTrackCOLMAPContext : NodeContext { Recording = false; SyncRecordPin(false); + UpdateRecordingFramePin(); UpdateFunctionOrphanStates(); UpdateStatus(); nosEngine.LogI("RecordTrackCOLMAP: Recording stopped (%zu frames in buffer)", Frames.size()); @@ -188,6 +191,12 @@ struct RecordTrackCOLMAPContext : NodeContext 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()) @@ -243,6 +252,7 @@ struct RecordTrackCOLMAPContext : NodeContext Frames.push_back(frame); UpdateFrameCountPin(); + UpdateRecordingFramePin(); UpdateStatus(); return NOS_RESULT_SUCCESS; From 8d19bd3029b8f96d4c498f5e2c365d319d0e99da Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Fri, 3 Apr 2026 18:58:38 +0300 Subject: [PATCH 05/30] Fix crash when invoking OpenFolder function in PlaybackTrackCOLMAP node --- Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp index d57b5b0c..8a23b353 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -449,7 +449,7 @@ struct PlaybackTrackCOLMAPContext : NodeContext }; names[2] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_OpenFolder"); - fns[3] = [](void* ctx, nosFunctionExecuteParams*) { + fns[2] = [](void* ctx, nosFunctionExecuteParams*) { auto* self = static_cast(ctx); if (self->InputDir.empty()) { From 6d1c7d2e19045916f6b4e0a306419b5281fe830e Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 8 Apr 2026 19:08:10 +0300 Subject: [PATCH 06/30] RecordTrackCOLMAP node should always execute --- Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef | 1 + Plugins/nosTrack/Track.noscfg | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 10e5c006..94a4f8cf 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -11,6 +11,7 @@ "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": [ { diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index c8a13052..464b68c7 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -2,7 +2,7 @@ "info": { "id": { "name": "nos.track", - "version": "1.10.0" + "version": "1.11.0" }, "display_name": "Track", "category": "Virtual Studio", From e89c723b5fbdcf6f07cc5ba64e6f9c82b0e15412 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 9 Apr 2026 13:30:04 +0300 Subject: [PATCH 07/30] Remove sequential playback mode from PlaybackTrackCOLMAP, keep manual frame index only Also fix frames not loading on node creation by always loading when InputDirectory or EulerOrder changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Config/PlaybackTrackCOLMAP.nosdef | 32 +--- .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 143 ++---------------- Subsystems/nosTrackSubsystem/Config/Track.fbs | 5 - 3 files changed, 12 insertions(+), 168 deletions(-) diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef index d0f260c5..ead9359d 100644 --- a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -12,7 +12,7 @@ "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. Sequential mode auto-advances each frame with Play/Stop control. Manual mode outputs the frame at a given index.", + "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", @@ -32,22 +32,6 @@ "data": "ZYX", "description": "Euler angle rotation order for converting COLMAP quaternion to Track rotation. Default ZYX matches the FreeD node convention." }, - { - "name": "Mode", - "type_name": "nos.track.PlaybackMode", - "show_as": "PROPERTY", - "can_show_as": "INPUT_PIN_OR_PROPERTY", - "data": "Sequential", - "description": "Sequential: auto-advances one frame per execution with Play/Stop control. Manual: outputs the frame at the given Frame Input index." - }, - { - "name": "Loop", - "type_name": "bool", - "show_as": "PROPERTY", - "can_show_as": "INPUT_PIN_OR_PROPERTY", - "data": true, - "description": "Loop playback to the beginning when the last frame is reached." - }, { "name": "InFrameIndex", "display_name": "Frame Index", @@ -55,7 +39,7 @@ "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": 0, - "description": "Frame index to output (Manual mode only)." + "description": "Frame index to output." }, { "name": "Track", @@ -84,18 +68,6 @@ } ], "functions": [ - { - "class_name": "PlaybackTrackCOLMAP_Play", - "display_name": "Play", - "contents_type": "Job", - "pins": [] - }, - { - "class_name": "PlaybackTrackCOLMAP_Stop", - "display_name": "Stop", - "contents_type": "Job", - "pins": [] - }, { "class_name": "PlaybackTrackCOLMAP_OpenFolder", "display_name": "Open Folder", diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp index 8a23b353..3a3e5641 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -20,14 +20,10 @@ namespace nos::track NOS_REGISTER_NAME_SPACED(Playback_InputDirectory, "InputDirectory"); NOS_REGISTER_NAME_SPACED(Playback_EulerOrder, "EulerOrder"); -NOS_REGISTER_NAME_SPACED(Playback_Mode, "Mode"); -NOS_REGISTER_NAME_SPACED(Playback_Loop, "Loop"); 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_Play); -NOS_REGISTER_NAME(PlaybackTrackCOLMAP_Stop); NOS_REGISTER_NAME(PlaybackTrackCOLMAP_OpenFolder); struct COLMAPCamera @@ -52,30 +48,17 @@ struct PlaybackTrackCOLMAPContext : NodeContext { std::string InputDir; track::EulerOrder EulerOrd = track::EulerOrder::ZYX; - track::PlaybackMode Mode = track::PlaybackMode::Sequential; - bool Loop = true; - bool Playing = false; - uint32_t ManualFrame = 0; + uint32_t FrameIndex = 0; std::string LastError; std::vector Frames; uint32_t CurrentFrame = 0; - std::unordered_map FunctionIds; - std::unordered_map PinIds; - PlaybackTrackCOLMAPContext(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()); - PinIds[name] = *pin->id(); if (flatbuffers::IsFieldPresent(pin, fb::Pin::VT_DATA)) { nosBuffer value = {.Data = (void*)pin->data()->data(), .Size = pin->data()->size()}; @@ -83,81 +66,28 @@ struct PlaybackTrackCOLMAPContext : NodeContext } } } - UpdateOrphanStates(); UpdateStatus(); } - void SetFunctionOrphanState(nos::Name funcName, fb::NodeOrphanStateType type) - { - auto it = FunctionIds.find(funcName); - if (it != FunctionIds.end()) - NodeContext::SetNodeOrphanState(it->second, type); - } - - void SetPinOrphanState(nos::Name pinName, fb::PinOrphanStateType type) - { - auto it = PinIds.find(pinName); - if (it != PinIds.end()) - NodeContext::SetPinOrphanState(it->second, type); - } - - void UpdateOrphanStates() - { - bool sequential = Mode == track::PlaybackMode::Sequential; - - // Sequential: Play/Stop active, Load/FrameInput orphaned - // Manual: Load/FrameInput active, Play/Stop orphaned - if (sequential) - { - SetPinOrphanState(NSN_Playback_InFrameIndex, fb::PinOrphanStateType::ORPHAN); - - if (Playing) - { - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ORPHAN); - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ACTIVE); - } - else - { - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ACTIVE); - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ORPHAN); - } - } - else - { - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Play, fb::NodeOrphanStateType::ORPHAN); - SetFunctionOrphanState(NSN_PlaybackTrackCOLMAP_Stop, fb::NodeOrphanStateType::ORPHAN); - SetPinOrphanState(NSN_Playback_InFrameIndex, fb::PinOrphanStateType::ACTIVE); - } - } - void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer val) override { if (pinName == NSN_Playback_InputDirectory) { InputDir = InterpretPinValue(val.Data); LastError.clear(); - if (Mode == track::PlaybackMode::Manual && !InputDir.empty()) + if (!InputDir.empty()) LoadFromDirectory(); else UpdateStatus(); } else if (pinName == NSN_Playback_EulerOrder) - EulerOrd = *(track::EulerOrder*)val.Data; - else if (pinName == NSN_Playback_Mode) { - auto newMode = *(track::PlaybackMode*)val.Data; - if (newMode != Mode) - { - Mode = newMode; - Playing = false; - UpdateOrphanStates(); - UpdateStatus(); - } + EulerOrd = *(track::EulerOrder*)val.Data; + if (!InputDir.empty()) + LoadFromDirectory(); } - else if (pinName == NSN_Playback_Loop) - Loop = *(bool*)val.Data; else if (pinName == NSN_Playback_InFrameIndex) - ManualFrame = *(uint32_t*)val.Data; + FrameIndex = *(uint32_t*)val.Data; } void UpdateFrameCountPin() @@ -179,8 +109,6 @@ struct PlaybackTrackCOLMAPContext : NodeContext SetNodeStatusMessage("Set input directory", fb::NodeStatusMessageType::WARNING); else if (Frames.empty()) SetNodeStatusMessage("No data loaded", fb::NodeStatusMessageType::WARNING); - else if (Mode == track::PlaybackMode::Sequential && Playing) - SetNodeStatusMessage("Playing (" + std::to_string(CurrentFrame + 1) + "/" + std::to_string(Frames.size()) + ")", fb::NodeStatusMessageType::INFO); else SetNodeStatusMessage("Loaded (" + std::to_string(Frames.size()) + " frames)", fb::NodeStatusMessageType::INFO); } @@ -381,75 +309,24 @@ struct PlaybackTrackCOLMAPContext : NodeContext return NOS_RESULT_SUCCESS; } - uint32_t frameIdx = 0; - if (Mode == track::PlaybackMode::Sequential) - { - if (!Playing) - { - frameIdx = CurrentFrame; - } - else - { - frameIdx = CurrentFrame; - uint32_t next = CurrentFrame + 1; - if (next >= (uint32_t)Frames.size()) - next = Loop ? 0 : (uint32_t)Frames.size() - 1; - CurrentFrame = next; - } - } - else - { - frameIdx = ManualFrame < (uint32_t)Frames.size() ? ManualFrame : (uint32_t)Frames.size() - 1; - CurrentFrame = frameIdx; - } + 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(); - if (Mode == track::PlaybackMode::Sequential && Playing) - UpdateStatus(); - return NOS_RESULT_SUCCESS; } static nosResult GetFunctions(size_t* count, nosName* names, nosPfnNodeFunctionExecute* fns) { - *count = 3; + *count = 1; if (!names || !fns) return NOS_RESULT_SUCCESS; - names[0] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_Play"); + names[0] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_OpenFolder"); fns[0] = [](void* ctx, nosFunctionExecuteParams*) { - auto* self = static_cast(ctx); - if (self->Playing) - return NOS_RESULT_SUCCESS; - if (self->Frames.empty()) - self->LoadFromDirectory(); - if (self->Frames.empty()) - return NOS_RESULT_SUCCESS; - self->Playing = true; - self->CurrentFrame = 0; - self->UpdateOrphanStates(); - self->UpdateStatus(); - nosEngine.LogI("PlaybackTrackCOLMAP: Playing (%zu frames)", self->Frames.size()); - return NOS_RESULT_SUCCESS; - }; - - names[1] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_Stop"); - fns[1] = [](void* ctx, nosFunctionExecuteParams*) { - auto* self = static_cast(ctx); - if (!self->Playing) - return NOS_RESULT_SUCCESS; - self->Playing = false; - self->UpdateOrphanStates(); - self->UpdateStatus(); - nosEngine.LogI("PlaybackTrackCOLMAP: Stopped at frame %u", self->CurrentFrame); - return NOS_RESULT_SUCCESS; - }; - - names[2] = NOS_NAME_STATIC("PlaybackTrackCOLMAP_OpenFolder"); - fns[2] = [](void* ctx, nosFunctionExecuteParams*) { auto* self = static_cast(ctx); if (self->InputDir.empty()) { diff --git a/Subsystems/nosTrackSubsystem/Config/Track.fbs b/Subsystems/nosTrackSubsystem/Config/Track.fbs index 2da4998e..75e6e1ff 100644 --- a/Subsystems/nosTrackSubsystem/Config/Track.fbs +++ b/Subsystems/nosTrackSubsystem/Config/Track.fbs @@ -51,8 +51,3 @@ enum EulerOrder : uint { ZXY = 4, XZY = 5, } - -enum PlaybackMode : uint { - Sequential = 0, - Manual = 1, -} From 6f537b0266738c47e078c2cf230024d24e3b6afb Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 9 Apr 2026 15:44:28 +0300 Subject: [PATCH 08/30] Bump nos.sys.track version --- Plugins/nosTrack/Track.noscfg | 2 +- Subsystems/nosTrackSubsystem/nosTrackSubsystem.nossys | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index 464b68c7..97473fef 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -9,7 +9,7 @@ "dependencies": [ { "name": "nos.sys.track", - "version": "1.0" + "version": "1.1" } ] }, 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": [ From 8b13957c4b20a111efc4ef0e7e758ebfd6fd9800 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 9 Apr 2026 18:00:47 +0300 Subject: [PATCH 09/30] Replace EulerOrder with CoordinateSystem from nos.sys.track Migrate COLMAP nodes from the removed plugin-local EulerOrder enum to the existing nos.sys.track.CoordinateSystem enum. Also fix nos.track namespace references to nos.sys.track after upstream merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Config/PlaybackTrackCOLMAP.nosdef | 8 ++--- .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 10 +++--- .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 32 +++++++++---------- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 28 ++++++++-------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef index ead9359d..67b8f74e 100644 --- a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -24,9 +24,9 @@ "description": "Directory containing cameras.txt and images.txt in COLMAP text format." }, { - "name": "EulerOrder", - "display_name": "Euler Order", - "type_name": "nos.track.EulerOrder", + "name": "CoordinateSystem", + "display_name": "Coordinate System", + "type_name": "nos.sys.track.CoordinateSystem", "show_as": "PROPERTY", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": "ZYX", @@ -43,7 +43,7 @@ }, { "name": "Track", - "type_name": "nos.track.Track", + "type_name": "nos.sys.track.Track", "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", "description": "Track data for the current frame." diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 94a4f8cf..2237b720 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -17,7 +17,7 @@ { "name": "InTrack", "display_name": "Track", - "type_name": "nos.track.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." @@ -25,7 +25,7 @@ { "name": "OutTrack", "display_name": "Track", - "type_name": "nos.track.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." @@ -52,9 +52,9 @@ "description": "Image resolution in pixels (width, height). Used to compute focal length and principal point for COLMAP camera model." }, { - "name": "EulerOrder", - "display_name": "Euler Order", - "type_name": "nos.track.EulerOrder", + "name": "CoordinateSystem", + "display_name": "Coordinate System", + "type_name": "nos.sys.track.CoordinateSystem", "show_as": "PROPERTY", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": "ZYX", diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp index 3a3e5641..567f929b 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -1,7 +1,7 @@ // Copyright MediaZ Teknoloji A.S. All Rights Reserved. #include -#include "Track_generated.h" +#include "nosSysTrack/Track_generated.h" #include #include @@ -19,7 +19,7 @@ namespace nos::track { NOS_REGISTER_NAME_SPACED(Playback_InputDirectory, "InputDirectory"); -NOS_REGISTER_NAME_SPACED(Playback_EulerOrder, "EulerOrder"); +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"); @@ -47,10 +47,10 @@ struct COLMAPImage struct PlaybackTrackCOLMAPContext : NodeContext { std::string InputDir; - track::EulerOrder EulerOrd = track::EulerOrder::ZYX; + sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; uint32_t FrameIndex = 0; std::string LastError; - std::vector Frames; + std::vector Frames; uint32_t CurrentFrame = 0; PlaybackTrackCOLMAPContext(nosFbNodePtr node) : NodeContext(node) { @@ -80,9 +80,9 @@ struct PlaybackTrackCOLMAPContext : NodeContext else UpdateStatus(); } - else if (pinName == NSN_Playback_EulerOrder) + else if (pinName == NSN_Playback_CoordinateSystem) { - EulerOrd = *(track::EulerOrder*)val.Data; + CoordSys = *(sys::track::CoordinateSystem*)val.Data; if (!InputDir.empty()) LoadFromDirectory(); } @@ -161,7 +161,7 @@ struct PlaybackTrackCOLMAPContext : NodeContext for (auto& img : images) { - track::TTrack trackData{}; + sys::track::TTrack trackData{}; auto camIt = cameras.find(img.CameraId); // Convert COLMAP world-to-camera back to camera-to-world @@ -169,7 +169,7 @@ struct PlaybackTrackCOLMAPContext : NodeContext glm::mat3 R_c2w = glm::transpose(R_w2c); glm::vec3 C = -R_c2w * img.T; - glm::vec3 euler = RotationMatrixToEuler(R_c2w, EulerOrd); + glm::vec3 euler = RotationMatrixToEuler(R_c2w, CoordSys); trackData.location = reinterpret_cast(C); trackData.rotation = reinterpret_cast(euler); @@ -280,18 +280,18 @@ struct PlaybackTrackCOLMAPContext : NodeContext // --- Euler extraction (inverse of EulerToRotationMatrix in RecordTrackCOLMAP) --- - static glm::vec3 RotationMatrixToEuler(const glm::mat3& R_c2w, track::EulerOrder order) + static glm::vec3 RotationMatrixToEuler(const glm::mat3& R_c2w, sys::track::CoordinateSystem order) { float r, t, p; switch (order) { default: - case track::EulerOrder::ZYX: glm::extractEulerAngleZYX(glm::mat4(R_c2w), p, t, r); break; - case track::EulerOrder::XYZ: glm::extractEulerAngleXYZ(glm::mat4(R_c2w), r, t, p); break; - case track::EulerOrder::YXZ: glm::extractEulerAngleYXZ(glm::mat4(R_c2w), t, r, p); break; - case track::EulerOrder::YZX: glm::extractEulerAngleYZX(glm::mat4(R_c2w), t, p, r); break; - case track::EulerOrder::ZXY: glm::extractEulerAngleZXY(glm::mat4(R_c2w), p, r, t); break; - case track::EulerOrder::XZY: glm::extractEulerAngleXZY(glm::mat4(R_c2w), r, p, t); break; + 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)); @@ -303,7 +303,7 @@ struct PlaybackTrackCOLMAPContext : NodeContext { if (Frames.empty()) { - track::TTrack 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; diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 8e269a0a..0284b2f6 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -1,7 +1,7 @@ // Copyright MediaZ Teknoloji A.S. All Rights Reserved. #include -#include "Track_generated.h" +#include "nosSysTrack/Track_generated.h" #include #include @@ -18,7 +18,7 @@ namespace nos::track NOS_REGISTER_NAME(OutputDirectory); NOS_REGISTER_NAME(ImageResolution); -NOS_REGISTER_NAME(EulerOrder); +NOS_REGISTER_NAME(CoordinateSystem); NOS_REGISTER_NAME(Record); NOS_REGISTER_NAME(FrameCount); NOS_REGISTER_NAME(RecordingFrame); @@ -45,7 +45,7 @@ struct RecordTrackCOLMAPContext : NodeContext { std::string OutputDir; nosVec2u ImageResolution = {1920, 1080}; - track::EulerOrder EulerOrd = track::EulerOrder::ZYX; + sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; bool Recording = false; bool SyncingRecordPin = false; std::string LastError; @@ -145,8 +145,8 @@ struct RecordTrackCOLMAPContext : NodeContext } else if (pinName == NSN_ImageResolution) ImageResolution = *(nosVec2u*)val.Data; - else if (pinName == NSN_EulerOrder) - EulerOrd = *(track::EulerOrder*)val.Data; + else if (pinName == NSN_CoordinateSystem) + CoordSys = *(sys::track::CoordinateSystem*)val.Data; else if (pinName == NSN_Record) { if (SyncingRecordPin) @@ -230,7 +230,7 @@ struct RecordTrackCOLMAPContext : NodeContext if (!Recording) return NOS_RESULT_SUCCESS; - auto* trackData = flatbuffers::GetRoot(trackBuf.Data); + auto* trackData = flatbuffers::GetRoot(trackBuf.Data); if (!trackData) return NOS_RESULT_SUCCESS; @@ -331,7 +331,7 @@ struct RecordTrackCOLMAPContext : NodeContext } } - static glm::mat3 EulerToRotationMatrix(glm::vec3 rot, track::EulerOrder order) + 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) @@ -339,12 +339,12 @@ struct RecordTrackCOLMAPContext : NodeContext switch (order) { default: - case track::EulerOrder::ZYX: return glm::mat3(glm::eulerAngleZYX(p, t, r)); - case track::EulerOrder::XYZ: return glm::mat3(glm::eulerAngleXYZ(r, t, p)); - case track::EulerOrder::YXZ: return glm::mat3(glm::eulerAngleYXZ(t, r, p)); - case track::EulerOrder::YZX: return glm::mat3(glm::eulerAngleYZX(t, p, r)); - case track::EulerOrder::ZXY: return glm::mat3(glm::eulerAngleZXY(p, r, t)); - case track::EulerOrder::XZY: return glm::mat3(glm::eulerAngleXZY(r, p, t)); + 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)); } } @@ -371,7 +371,7 @@ struct RecordTrackCOLMAPContext : NodeContext // 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, EulerOrd); + glm::mat3 R_c2w = EulerToRotationMatrix(rot, CoordSys); // COLMAP expects world-to-camera rotation glm::mat3 R_w2c = glm::transpose(R_c2w); From 3731d87b516b711092b89da7c9128864da628e7d Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 28 Apr 2026 14:08:42 +0300 Subject: [PATCH 10/30] Multi Ring/Queue --- .../Config/MultiBoundedQueue.nosdef | 56 ++ .../Config/MultiRingBuffer.nosdef | 74 ++ .../nosUtilities/Source/MultiBoundedQueue.cpp | 583 +++++++++++++++ .../nosUtilities/Source/MultiRingBuffer.cpp | 705 ++++++++++++++++++ Plugins/nosUtilities/Source/UtilitiesMain.cpp | 6 + Plugins/nosUtilities/Utilities.noscfg | 2 + 6 files changed, 1426 insertions(+) create mode 100644 Plugins/nosUtilities/Config/MultiBoundedQueue.nosdef create mode 100644 Plugins/nosUtilities/Config/MultiRingBuffer.nosdef create mode 100644 Plugins/nosUtilities/Source/MultiBoundedQueue.cpp create mode 100644 Plugins/nosUtilities/Source/MultiRingBuffer.cpp diff --git a/Plugins/nosUtilities/Config/MultiBoundedQueue.nosdef b/Plugins/nosUtilities/Config/MultiBoundedQueue.nosdef new file mode 100644 index 00000000..df1ef4c8 --- /dev/null +++ b/Plugins/nosUtilities/Config/MultiBoundedQueue.nosdef @@ -0,0 +1,56 @@ +{ + "nodes": [ + { + "class_name": "MultiBoundedQueue", + "menu_info": { + "category": "Utilities", + "display_name": "Multi Bounded Queue", + "name_aliases": [ "data structure", "algorithm", "circular", "multi", "fifo" ] + }, + "node": { + "class_name": "MultiBoundedQueue", + "display_name": "Multi Bounded Queue", + "contents_type": "Job", + "description": "Bounded FIFO queue with one or more independent input/output channel pairs sharing a single bound. Right-click the node to add a channel, right-click an Input_X or Output_X pin to remove its channel.", + "pins": [ + { + "name": "Thread", + "type_name": "nos.exe", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Size", + "type_name": "uint", + "data": 2, + "max": 120, + "min": 1, + "show_as": "PROPERTY", + "can_show_as": "PROPERTY_ONLY" + }, + { + "name": "Alignment", + "description": "Used for creating memory-aligned buffers in memory", + "type_name": "uint", + "def": 0, + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Input_A", + "type_name": "nos.Generic", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Output_A", + "type_name": "nos.Generic", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "live": true + } + ] + } + } + ] +} diff --git a/Plugins/nosUtilities/Config/MultiRingBuffer.nosdef b/Plugins/nosUtilities/Config/MultiRingBuffer.nosdef new file mode 100644 index 00000000..3fa2c4c3 --- /dev/null +++ b/Plugins/nosUtilities/Config/MultiRingBuffer.nosdef @@ -0,0 +1,74 @@ +{ + "nodes": [ + { + "class_name": "MultiRingBuffer", + "menu_info": { + "category": "Utilities", + "display_name": "Multi Ring Buffer", + "name_aliases": [ "data structure", "algorithm", "circular", "multi" ] + }, + "node": { + "class_name": "MultiRingBuffer", + "display_name": "Multi Ring Buffer", + "contents_type": "Job", + "description": "Ring buffer with one or more independent input/output channel pairs sharing a single ring size. Right-click the node to add a channel, right-click an Input_X or Output_X pin to remove its channel.", + "pins": [ + { + "name": "Thread", + "type_name": "nos.exe", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Size", + "type_name": "uint", + "data": 2, + "max": 120, + "min": 1, + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Spare", + "type_name": "uint", + "data": 0, + "max": 119, + "min": 0, + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Alignment", + "description": "Used for creating memory-aligned buffers in memory", + "type_name": "uint", + "def": 0, + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Input_A", + "type_name": "nos.Generic", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Output_A", + "type_name": "nos.Generic", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "live": true + }, + { + "name": "RepeatWhenFilling", + "display_name": "Repeat When Filling", + "type_name": "bool", + "description": "Serves the last value while the buffer is being filled instead of waiting & resets the ring on restart", + "def": true, + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + } + ] + } + } + ] +} diff --git a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp new file mode 100644 index 00000000..4b7175a4 --- /dev/null +++ b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp @@ -0,0 +1,583 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#pragma once + +#include + +// External +#include +#include + +#include "Ring.h" +#include "nosUtil/Stopwatch.hpp" + +namespace nos::utilities +{ + +struct MultiBoundedQueueNodeContext : NodeContext +{ + using RingMode = RingNodeBase::RingMode; + using OnRestartType = RingNodeBase::OnRestartType; + + static constexpr std::string_view CHANNEL_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + enum MenuCommandType : uint8_t + { + ADD_CHANNEL = 0, + REMOVE_CHANNEL = 1, + }; + + struct MenuCommand + { + MenuCommandType Type; + uint8_t Letter; + MenuCommand(uint32_t cmd) + { + Type = static_cast(cmd & 0xFF); + Letter = static_cast((cmd >> 8) & 0xFF); + } + MenuCommand(MenuCommandType type, uint8_t letter) : Type(type), Letter(letter) {} + operator uint32_t() const { return (Letter << 8) | Type; } + }; + + struct Channel + { + char Letter; + nos::Name InputName; + nos::Name OutputName; + uuid InputId{}; + uuid OutputId{}; + nos::TypeInfo TypeInfo; + std::unique_ptr Ring; + std::atomic_bool IsOutLive = false; + bool NeedsRecreation = false; + + Channel(char letter) + : Letter(letter), + InputName((std::string("Input_") + letter).c_str()), + OutputName((std::string("Output_") + letter).c_str()), + TypeInfo(NSN_Generic) + { + } + }; + + std::map> Channels; + std::unordered_map PinIdToLetter; + + std::optional RequestedRingSize = std::nullopt; + + std::string GetName() const { return "MultiBoundedQueue"; } + + static std::optional ParseLetter(std::string_view pinName) + { + auto pos = pinName.find_last_of('_'); + if (pos == std::string::npos || pos + 2 != pinName.size()) + return std::nullopt; + char c = pinName[pos + 1]; + if (c < 'A' || c > 'Z') + return std::nullopt; + return c; + } + + static bool IsInputPin(std::string_view pinName) { return pinName.starts_with("Input_"); } + static bool IsOutputPin(std::string_view pinName) { return pinName.starts_with("Output_"); } + + MultiBoundedQueueNodeContext(nosFbNodePtr node) : NodeContext(node) + { + std::vector pinsToUnorphan; + for (auto* pin : *node->pins()) + { + auto pinNameSv = pin->name()->string_view(); + if (!IsInputPin(pinNameSv) && !IsOutputPin(pinNameSv)) + continue; + auto letter = ParseLetter(pinNameSv); + if (!letter) + continue; + + auto& channel = Channels[*letter]; + if (!channel) + channel = std::make_unique(*letter); + + if (IsInputPin(pinNameSv)) + channel->InputId = uuid(*pin->id()); + else + { + channel->OutputId = uuid(*pin->id()); + channel->IsOutLive = pin->live(); + } + PinIdToLetter[uuid(*pin->id())] = *letter; + + nos::Name typeName(pin->type_name()->c_str()); + if (typeName != NSN_Generic && channel->TypeInfo->TypeName == NSN_Generic) + channel->TypeInfo = nos::TypeInfo(typeName); + + if (auto orphanState = pin->orphan_state()) + if (orphanState->type() == fb::PinOrphanStateType::ORPHAN) + pinsToUnorphan.push_back(uuid(*pin->id())); + } + + for (auto& [_, ch] : Channels) + InitChannel(*ch); + + for (auto const& pinId : pinsToUnorphan) + SetPinOrphanState(pinId, fb::PinOrphanStateType::ACTIVE); + + AddPinValueWatcher(NSN_Size, [this](nos::Buffer const& newSize, std::optional oldVal) { + uint32_t size = *newSize.As(); + if (oldVal && oldVal == newSize) + return; + RequestRingResize(size); + }); + AddPinValueWatcher(NSN_Alignment, [this](nos::Buffer const& newAlignment, std::optional oldVal) { + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + if (ch->Ring->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) + { + nosEngine.SendPathRestart(ch->InputId); + ch->Ring->Stop(); + ch->NeedsRecreation = true; + } + } + }); + } + + ~MultiBoundedQueueNodeContext() override + { + for (auto& [_, ch] : Channels) + if (ch->Ring) + ch->Ring->Stop(); + } + + void InitChannel(Channel& ch) + { + std::shared_ptr resource; + if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Buffer::GetFullyQualifiedName())) + resource = std::make_shared(); + else if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Texture::GetFullyQualifiedName())) + resource = std::make_shared(); + else + resource = std::make_shared(); + + ch.Ring = std::make_unique(1, std::move(resource)); + ch.Ring->Stop(); + } + + Channel* GetChannelByPinId(uuid const& id) + { + auto it = PinIdToLetter.find(id); + if (it == PinIdToLetter.end()) + return nullptr; + auto chIt = Channels.find(it->second); + return chIt != Channels.end() ? chIt->second.get() : nullptr; + } + + void RequestRingResize(uint32_t size) + { + if (size == 0) + { + nosEngine.LogW((GetName() + " size cannot be 0").c_str()); + return; + } + bool changed = false; + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + if (ch->Ring->Size != size && (!RequestedRingSize.has_value() || *RequestedRingSize != size)) + { + nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; + nosEngine.SendPathCommand(ch->InputId, ringSizeChange); + ch->Ring->Stop(); + changed = true; + } + } + if (changed) + { + SendPathRestart(); + RequestedRingSize = size; + } + } + + void SendPathRestart() + { + for (auto& [_, ch] : Channels) + nosEngine.SendPathRestart(ch->InputId); + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer value) override + { + auto sv = pinName.AsString(); + if (!IsInputPin(sv)) + return; + auto* ch = GetChannelByPinId(pinId); + if (!ch || !ch->Ring) + return; + if (ch->Ring->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) + { + nosEngine.SendPathRestart(ch->InputId); + ch->Ring->Stop(); + ch->NeedsRecreation = true; + } + } + + nosResult OnResolvePinDataTypes(nosResolvePinDataTypesParams* params) override + { + auto pinNameStr = nos::Name(params->InstigatorPinName).AsString(); + auto letter = ParseLetter(pinNameStr); + if (!letter) + return NOS_RESULT_FAILED; + auto chIt = Channels.find(*letter); + if (chIt == Channels.end()) + return NOS_RESULT_FAILED; + auto& ch = *chIt->second; + if (ch.TypeInfo->TypeName != NSN_Generic) + return NOS_RESULT_FAILED; + ch.TypeInfo = nos::TypeInfo(params->IncomingTypeName); + if (ch.Ring) + ch.Ring->Stop(); + ch.Ring.reset(); + for (size_t i = 0; i < params->PinCount; i++) + { + auto& pinInfo = params->Pins[i]; + if (pinInfo.Id == ch.InputId || pinInfo.Id == ch.OutputId) + pinInfo.OutResolvedTypeName = ch.TypeInfo->TypeName; + } + return NOS_RESULT_SUCCESS; + } + + void OnPinUpdated(const nosPinUpdate*) override + { + for (auto& [_, ch] : Channels) + if (!ch->Ring) + InitChannel(*ch); + } + + void OnNodeUpdated(nosNodeUpdate const* update) override + { + if (update->Type == NOS_NODE_UPDATE_PIN_DELETED) + { + auto it = PinIdToLetter.find(update->PinDeleted); + if (it == PinIdToLetter.end()) + return; + char letter = it->second; + PinIdToLetter.erase(it); + auto chIt = Channels.find(letter); + if (chIt == Channels.end()) + return; + auto& ch = *chIt->second; + bool inputAlive = PinIdToLetter.contains(ch.InputId); + bool outputAlive = PinIdToLetter.contains(ch.OutputId); + if (!inputAlive && !outputAlive) + { + if (ch.Ring) + ch.Ring->Stop(); + Channels.erase(chIt); + } + } + else if (update->Type == NOS_NODE_UPDATE_PIN_CREATED) + { + auto* pin = update->PinCreated; + auto sv = pin->name()->string_view(); + if (!IsInputPin(sv) && !IsOutputPin(sv)) + return; + auto letter = ParseLetter(sv); + if (!letter) + return; + auto& chPtr = Channels[*letter]; + if (!chPtr) + chPtr = std::make_unique(*letter); + if (IsInputPin(sv)) + chPtr->InputId = uuid(*pin->id()); + else + { + chPtr->OutputId = uuid(*pin->id()); + chPtr->IsOutLive = pin->live(); + } + PinIdToLetter[uuid(*pin->id())] = *letter; + if (!chPtr->Ring) + InitChannel(*chPtr); + } + } + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + if (Channels.empty()) + return NOS_RESULT_FAILED; + + NodeExecuteParams pins(params); + uint32_t requestedSize = *pins.GetPinData(NSN_Size); + + std::vector> inputs; + inputs.reserve(Channels.size()); + uint32_t maxRequired = requestedSize; + for (auto& [_, ch] : Channels) + { + if (!ch->Ring || ch->Ring->Exit || !ch->Ring->IsResourcesValid() || !ch->TypeInfo) + continue; + auto it = pins.find(ch->InputName); + if (it == pins.end()) + continue; + void* input = ch->Ring->ResInterface->GetPinInfo(it->second, false); + if (!input) + continue; + auto [required, _] = ch->Ring->ResInterface->GetRequiredRingSize(input, requestedSize); + if (required > maxRequired) + maxRequired = required; + inputs.emplace_back(ch.get(), input); + } + if (inputs.empty()) + { + SendScheduleRequest(0); + return NOS_RESULT_FAILED; + } + + bool anyResize = false; + for (auto& [ch, _] : inputs) + if (ch->Ring->Size != maxRequired) + { + anyResize = true; + break; + } + if (anyResize) + { + RequestRingResize(maxRequired); + return NOS_RESULT_FAILED; + } + + std::vector slots(inputs.size(), nullptr); + for (size_t i = 0; i < inputs.size(); ++i) + { + auto* ch = inputs[i].first; + auto* slot = ch->Ring->BeginPush(100); + if (!slot) + { + for (size_t j = 0; j < i; ++j) + inputs[j].first->Ring->CancelPush(slots[j]); + if (ch->Ring->Exit) + return NOS_RESULT_FAILED; + return NOS_RESULT_PENDING; + } + slots[i] = slot; + } + + for (size_t i = 0; i < inputs.size(); ++i) + { + auto* ch = inputs[i].first; + ch->Ring->ResInterface->Push(slots[i], inputs[i].second, params, + NOS_NAME_STATIC("MultiBoundedQueue"), false); + ch->Ring->EndPush(slots[i]); + if (!ch->IsOutLive) + { + ChangePinLiveness(ch->OutputName, true); + ch->IsOutLive = true; + } + } + + return NOS_RESULT_SUCCESS; + } + + nosResult CopyFrom(nosCopyInfo* cpy) override + { + auto* ch = GetChannelByPinId(cpy->ID); + if (!ch || !ch->Ring || ch->Ring->Exit) + return NOS_RESULT_FAILED; + if (!ch->IsOutLive) + return NOS_RESULT_SUCCESS; + + ResourceInterface::ResourceBase* slot; + { + ScopedProfilerEvent _({.Name = "Wait For Filled Slot"}); + slot = ch->Ring->BeginPop(100); + } + if (!slot) + return ch->Ring->Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; + + ch->Ring->ResInterface->Copy(slot, cpy, NodeId); + + cpy->CopyFromOptions.ShouldSetSourceFrameNumber = true; + cpy->FrameNumber = slot->FrameNumber; + + ch->Ring->EndPop(slot); + SendScheduleRequest(1); + return NOS_RESULT_SUCCESS; + } + + void OnEndFrame(uuid const& pinId, nosEndFrameCause cause) override + { + if (cause != NOS_END_FRAME_FAILED) + return; + auto* ch = GetChannelByPinId(pinId); + if (!ch) + return; + if (pinId == ch->OutputId) + return; + if (!ch->IsOutLive) + return; + ChangePinLiveness(ch->OutputName, false); + ch->IsOutLive = false; + } + + void SendScheduleRequest(uint32_t count, bool reset = false) const + { + nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = count, .Reset = reset}; + nosEngine.ScheduleNode(&schedule); + } + + void OnPathCommand(const nosPathCommand* command) override + { + switch (command->Event) + { + case NOS_RING_SIZE_CHANGE: + if (command->RingSize == 0) + return; + RequestedRingSize = command->RingSize; + nosEngine.SetPinValue(*GetPinId(NSN_Size), nos::Buffer::From(command->RingSize)); + break; + default: return; + } + } + + void OnPathStop() override + { + for (auto& [_, ch] : Channels) + if (ch->Ring) + ch->Ring->Stop(); + } + + void OnPathStart() override + { + if (Channels.empty()) + return; + size_t totalSchedule = 0; + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + // FIFO restart: drop any frames left from the previous run. + ch->Ring->Reset(false); + if (RequestedRingSize) + { + ch->Ring->Resize(*RequestedRingSize); + ch->NeedsRecreation = false; + } + if (ch->NeedsRecreation) + { + ch->Ring = std::make_unique(ch->Ring->Size, ch->Ring->ResInterface); + ch->NeedsRecreation = false; + } + if (!ch->Ring->IsResourcesValid()) + { + totalSchedule = std::max(totalSchedule, 1); + continue; + } + auto emptySlotCount = ch->Ring->Write.Pool.size(); + totalSchedule = std::max(totalSchedule, emptySlotCount); + ch->Ring->Exit = false; + ch->Ring->ResInterface->OnPathStart(); + } + RequestedRingSize = std::nullopt; + if (totalSchedule > 0) + { + nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = (uint32_t)totalSchedule}; + nosEngine.ScheduleNode(&schedule); + } + } + + void OnNodeMenuRequested(nosContextMenuRequestPtr request) override + { + flatbuffers::FlatBufferBuilder fbb; + std::vector items = { + nos::CreateContextMenuItemDirect(fbb, "Add Channel", MenuCommand(ADD_CHANNEL, 0))}; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnPinMenuRequested(nos::Name pinName, nosContextMenuRequestPtr request) override + { + auto sv = pinName.AsString(); + if (!IsInputPin(sv) && !IsOutputPin(sv)) + return; + auto letter = ParseLetter(sv); + if (!letter) + return; + if (Channels.size() <= 1) + return; + flatbuffers::FlatBufferBuilder fbb; + std::vector items = {nos::CreateContextMenuItemDirect( + fbb, "Remove Channel", MenuCommand(REMOVE_CHANNEL, static_cast(*letter)))}; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnMenuCommand(uuid const& itemID, uint32_t cmd) override + { + auto command = MenuCommand(cmd); + switch (command.Type) + { + case ADD_CHANNEL: + { + char newLetter = 0; + for (char c : CHANNEL_LETTERS) + { + if (!Channels.contains(c)) + { + newLetter = c; + break; + } + } + if (newLetter == 0) + { + SetNodeStatusMessage("Maximum number of channels reached", fb::NodeStatusMessageType::WARNING); + return; + } + + fb::TPin inPin; + inPin.id = uuid(nosEngine.GenerateID()); + inPin.name = std::string("Input_") + newLetter; + inPin.type_name = "nos.Generic"; + inPin.show_as = fb::ShowAs::INPUT_PIN; + inPin.can_show_as = fb::CanShowAs::INPUT_PIN_ONLY; + + fb::TPin outPin; + outPin.id = uuid(nosEngine.GenerateID()); + outPin.name = std::string("Output_") + newLetter; + outPin.type_name = "nos.Generic"; + outPin.show_as = fb::ShowAs::OUTPUT_PIN; + outPin.can_show_as = fb::CanShowAs::OUTPUT_PIN_ONLY; + outPin.live = true; + + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_add.emplace_back(std::make_unique(std::move(inPin))); + update.pins_to_add.emplace_back(std::make_unique(std::move(outPin))); + flatbuffers::FlatBufferBuilder fbb; + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + break; + } + case REMOVE_CHANNEL: + { + char letter = static_cast(command.Letter); + auto it = Channels.find(letter); + if (it == Channels.end()) + return; + auto& ch = *it->second; + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_delete = {ch.InputId, ch.OutputId}; + flatbuffers::FlatBufferBuilder fbb; + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + break; + } + } + } +}; + +nosResult RegisterMultiBoundedQueue(nosNodeFunctions* functions) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("MultiBoundedQueue"), MultiBoundedQueueNodeContext, functions) + return NOS_RESULT_SUCCESS; +} + +} // namespace nos::utilities diff --git a/Plugins/nosUtilities/Source/MultiRingBuffer.cpp b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp new file mode 100644 index 00000000..a13c7f09 --- /dev/null +++ b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp @@ -0,0 +1,705 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#pragma once + +#include + +// External +#include +#include + +#include "Ring.h" +#include "nosUtil/Stopwatch.hpp" + +namespace nos::utilities +{ + +struct MultiRingBufferNodeContext : NodeContext +{ + using RingMode = RingNodeBase::RingMode; + using OnRestartType = RingNodeBase::OnRestartType; + + static constexpr std::string_view CHANNEL_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + enum MenuCommandType : uint8_t + { + ADD_CHANNEL = 0, + REMOVE_CHANNEL = 1, + }; + + struct MenuCommand + { + MenuCommandType Type; + uint8_t Letter; + MenuCommand(uint32_t cmd) + { + Type = static_cast(cmd & 0xFF); + Letter = static_cast((cmd >> 8) & 0xFF); + } + MenuCommand(MenuCommandType type, uint8_t letter) : Type(type), Letter(letter) {} + operator uint32_t() const { return (Letter << 8) | Type; } + }; + + struct Channel + { + char Letter; + nos::Name InputName; + nos::Name OutputName; + uuid InputId{}; + uuid OutputId{}; + nos::TypeInfo TypeInfo; + std::unique_ptr Ring; + std::atomic_bool IsOutLive = false; + ResourceInterface::ResourceBase* LastPopped = nullptr; + bool NeedsRecreation = false; + std::size_t RemainingRepeatableCount = 0; + + Channel(char letter) + : Letter(letter), + InputName((std::string("Input_") + letter).c_str()), + OutputName((std::string("Output_") + letter).c_str()), + TypeInfo(NSN_Generic) + { + } + }; + + std::map> Channels; + std::unordered_map PinIdToLetter; + + OnRestartType OnRestart = OnRestartType::WAIT_UNTIL_FULL; + std::optional RequestedRingSize = std::nullopt; + std::atomic Mode = RingMode::CONSUME; + std::condition_variable ModeCV; + std::mutex ModeMutex; + std::atomic_bool RepeatWhenFilling = false; + + std::string GetName() const { return "MultiRingBuffer"; } + + static std::optional ParseLetter(std::string_view pinName) + { + auto pos = pinName.find_last_of('_'); + if (pos == std::string::npos || pos + 2 != pinName.size()) + return std::nullopt; + char c = pinName[pos + 1]; + if (c < 'A' || c > 'Z') + return std::nullopt; + return c; + } + + static bool IsInputPin(std::string_view pinName) { return pinName.starts_with("Input_"); } + static bool IsOutputPin(std::string_view pinName) { return pinName.starts_with("Output_"); } + + MultiRingBufferNodeContext(nosFbNodePtr node) : NodeContext(node) + { + std::vector pinsToUnorphan; + for (auto* pin : *node->pins()) + { + auto pinNameSv = pin->name()->string_view(); + if (!IsInputPin(pinNameSv) && !IsOutputPin(pinNameSv)) + continue; + auto letter = ParseLetter(pinNameSv); + if (!letter) + continue; + + auto& channel = Channels[*letter]; + if (!channel) + channel = std::make_unique(*letter); + + if (IsInputPin(pinNameSv)) + channel->InputId = uuid(*pin->id()); + else + { + channel->OutputId = uuid(*pin->id()); + channel->IsOutLive = pin->live(); + } + PinIdToLetter[uuid(*pin->id())] = *letter; + + nos::Name typeName(pin->type_name()->c_str()); + if (typeName != NSN_Generic && channel->TypeInfo->TypeName == NSN_Generic) + channel->TypeInfo = nos::TypeInfo(typeName); + + if (auto orphanState = pin->orphan_state()) + if (orphanState->type() == fb::PinOrphanStateType::ORPHAN) + pinsToUnorphan.push_back(uuid(*pin->id())); + } + + for (auto& [_, ch] : Channels) + InitChannel(*ch); + + for (auto const& pinId : pinsToUnorphan) + SetPinOrphanState(pinId, fb::PinOrphanStateType::ACTIVE); + + AddPinValueWatcher(NSN_Size, [this](nos::Buffer const& newSize, std::optional oldVal) { + uint32_t size = *newSize.As(); + if (oldVal && oldVal == newSize) + return; + RequestRingResize(size); + }); + AddPinValueWatcher(NSN_Alignment, [this](nos::Buffer const& newAlignment, std::optional oldVal) { + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + if (ch->Ring->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) + { + nosEngine.SendPathRestart(ch->InputId); + ch->Ring->Stop(); + ch->NeedsRecreation = true; + } + } + }); + AddPinValueWatcher(NOS_NAME_STATIC("RepeatWhenFilling"), + [this](nos::Buffer const& newVal, std::optional oldVal) { + RepeatWhenFilling = *newVal.As(); + }); + } + + ~MultiRingBufferNodeContext() override + { + for (auto& [_, ch] : Channels) + { + NOS_SOFT_CHECK(ch->LastPopped == nullptr); + if (ch->Ring) + ch->Ring->Stop(); + } + } + + void InitChannel(Channel& ch) + { + std::shared_ptr resource; + if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Buffer::GetFullyQualifiedName())) + resource = std::make_shared(); + else if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Texture::GetFullyQualifiedName())) + resource = std::make_shared(); + else + resource = std::make_shared(); + + ch.Ring = std::make_unique(1, std::move(resource)); + ch.Ring->Stop(); + } + + Channel* GetChannelByPinId(uuid const& id) + { + auto it = PinIdToLetter.find(id); + if (it == PinIdToLetter.end()) + return nullptr; + auto chIt = Channels.find(it->second); + return chIt != Channels.end() ? chIt->second.get() : nullptr; + } + + void SeedOutputPin(Channel& ch) + { + if (!ch.Ring || !ch.Ring->IsResourcesValid()) + return; + auto* base = ch.Ring->Resources[0].get(); + if (!base) + return; + if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Buffer::GetFullyQualifiedName())) + { + if (auto* res = ResourceInterface::GetResource(base)) + nosEngine.SetPinValueByName(NodeId, ch.OutputName, res->VkRes.ToPinData()); + } + else if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Texture::GetFullyQualifiedName())) + { + if (auto* res = ResourceInterface::GetResource(base)) + { + sys::vulkan::TTexture texDef = vkss::ConvertTextureInfo(res->VkRes); + texDef.unscaled = true; + nosEngine.SetPinValueByName(NodeId, ch.OutputName, nos::Buffer::From(texDef)); + } + } + } + + void RequestRingResize(uint32_t size) + { + if (size == 0) + { + nosEngine.LogW((GetName() + " size cannot be 0").c_str()); + return; + } + bool changed = false; + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + if (ch->Ring->Size != size && (!RequestedRingSize.has_value() || *RequestedRingSize != size)) + { + nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; + nosEngine.SendPathCommand(ch->InputId, ringSizeChange); + ch->Ring->Stop(); + changed = true; + } + } + if (changed) + { + SendPathRestart(); + RequestedRingSize = size; + } + } + + void SendPathRestart() + { + for (auto& [_, ch] : Channels) + nosEngine.SendPathRestart(ch->InputId); + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer value) override + { + auto sv = pinName.AsString(); + if (!IsInputPin(sv)) + return; + auto* ch = GetChannelByPinId(pinId); + if (!ch || !ch->Ring) + return; + if (ch->Ring->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) + { + nosEngine.SendPathRestart(ch->InputId); + ch->Ring->Stop(); + ch->NeedsRecreation = true; + } + } + + nosResult OnResolvePinDataTypes(nosResolvePinDataTypesParams* params) override + { + auto pinNameStr = nos::Name(params->InstigatorPinName).AsString(); + auto letter = ParseLetter(pinNameStr); + if (!letter) + return NOS_RESULT_FAILED; + auto chIt = Channels.find(*letter); + if (chIt == Channels.end()) + return NOS_RESULT_FAILED; + auto& ch = *chIt->second; + if (ch.TypeInfo->TypeName != NSN_Generic) + return NOS_RESULT_FAILED; + ch.TypeInfo = nos::TypeInfo(params->IncomingTypeName); + // Drop the Generic-fallback ring so OnPinUpdated re-inits with the resolved type. + if (ch.Ring) + ch.Ring->Stop(); + ch.Ring.reset(); + for (size_t i = 0; i < params->PinCount; i++) + { + auto& pinInfo = params->Pins[i]; + if (pinInfo.Id == ch.InputId || pinInfo.Id == ch.OutputId) + pinInfo.OutResolvedTypeName = ch.TypeInfo->TypeName; + } + return NOS_RESULT_SUCCESS; + } + + void OnPinUpdated(const nosPinUpdate*) override + { + for (auto& [_, ch] : Channels) + if (!ch->Ring) + InitChannel(*ch); + } + + void OnNodeUpdated(nosNodeUpdate const* update) override + { + if (update->Type == NOS_NODE_UPDATE_PIN_DELETED) + { + auto it = PinIdToLetter.find(update->PinDeleted); + if (it == PinIdToLetter.end()) + return; + char letter = it->second; + PinIdToLetter.erase(it); + auto chIt = Channels.find(letter); + if (chIt == Channels.end()) + return; + auto& ch = *chIt->second; + bool inputAlive = PinIdToLetter.contains(ch.InputId); + bool outputAlive = PinIdToLetter.contains(ch.OutputId); + if (!inputAlive && !outputAlive) + { + if (ch.Ring) + ch.Ring->Stop(); + Channels.erase(chIt); + } + } + else if (update->Type == NOS_NODE_UPDATE_PIN_CREATED) + { + auto* pin = update->PinCreated; + auto sv = pin->name()->string_view(); + if (!IsInputPin(sv) && !IsOutputPin(sv)) + return; + auto letter = ParseLetter(sv); + if (!letter) + return; + auto& chPtr = Channels[*letter]; + if (!chPtr) + chPtr = std::make_unique(*letter); + if (IsInputPin(sv)) + chPtr->InputId = uuid(*pin->id()); + else + { + chPtr->OutputId = uuid(*pin->id()); + chPtr->IsOutLive = pin->live(); + } + PinIdToLetter[uuid(*pin->id())] = *letter; + if (!chPtr->Ring) + InitChannel(*chPtr); + } + } + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + if (Channels.empty()) + return NOS_RESULT_FAILED; + NodeExecuteParams pins(params); + uint32_t requestedSize = *pins.GetPinData(NSN_Size); + + std::vector> inputs; + inputs.reserve(Channels.size()); + uint32_t maxRequired = requestedSize; + std::string adjustMessage; + for (auto& [letter, ch] : Channels) + { + // Skip channels whose ring isn't ready yet (e.g. just added, + // awaiting OnNodeUpdated/OnPinUpdated to finish init). + if (!ch->Ring || ch->Ring->Exit || !ch->Ring->IsResourcesValid() || !ch->TypeInfo) + continue; + auto it = pins.find(ch->InputName); + if (it == pins.end()) + continue; + void* input = ch->Ring->ResInterface->GetPinInfo(it->second, true); + if (!input) + continue; + auto [required, message] = ch->Ring->ResInterface->GetRequiredRingSize(input, requestedSize); + if (required > maxRequired) + { + maxRequired = required; + adjustMessage = message; + } + inputs.emplace_back(ch.get(), input); + } + if (inputs.empty()) + { + SendScheduleRequest(0); + return NOS_RESULT_FAILED; + } + + bool effectiveSizeAdjusted = maxRequired != requestedSize; + ClearNodeStatusMessages(); + if (effectiveSizeAdjusted) + SetNodeStatusMessage(adjustMessage, fb::NodeStatusMessageType::WARNING); + + bool anyResize = false; + for (auto& [ch, _] : inputs) + if (ch->Ring->Size != maxRequired) + { + anyResize = true; + break; + } + + if (anyResize) + { + RequestRingResize(maxRequired); + if (effectiveSizeAdjusted) + nosEngine.LogW("%s", adjustMessage.c_str()); + return NOS_RESULT_FAILED; + } + + std::vector slots(inputs.size(), nullptr); + for (size_t i = 0; i < inputs.size(); ++i) + { + auto* ch = inputs[i].first; + auto* slot = ch->Ring->BeginPush(100); + if (!slot) + { + for (size_t j = 0; j < i; ++j) + inputs[j].first->Ring->CancelPush(slots[j]); + if (ch->Ring->Exit) + return NOS_RESULT_FAILED; + return NOS_RESULT_PENDING; + } + slots[i] = slot; + } + + for (size_t i = 0; i < inputs.size(); ++i) + { + auto* ch = inputs[i].first; + ch->Ring->ResInterface->Push(slots[i], inputs[i].second, params, + NOS_NAME_STATIC("MultiRingBuffer"), true); + ch->Ring->EndPush(slots[i]); + if (!ch->IsOutLive) + { + ChangePinLiveness(ch->OutputName, true); + ch->IsOutLive = true; + } + } + + if (Mode == RingMode::FILL) + { + bool isFillComplete = true; + for (auto& [ch, _] : inputs) + if (ch->Ring->Write.Pool.size() != 0) + { + isFillComplete = false; + break; + } + if (isFillComplete) + { + Mode = RingMode::CONSUME; + ModeCV.notify_all(); + } + } + + return NOS_RESULT_SUCCESS; + } + + nosResult CopyFrom(nosCopyInfo* cpy) override + { + auto* ch = GetChannelByPinId(cpy->ID); + if (!ch || !ch->Ring || ch->Ring->Exit) + return NOS_RESULT_FAILED; + if (!ch->IsOutLive) + return NOS_RESULT_SUCCESS; + + // EndPop the previous frame's slot before popping a new one. We can't + // rely on OnEndFrame: the engine only fires it on the path's primary + // source pin, so live secondary outputs (e.g. a second channel feeding + // the same consumer) never receive it. By the time the consumer asks + // for the next frame on this pin, it's done with the previous one. + if (ch->LastPopped) + { + ch->Ring->EndPop(ch->LastPopped); + ch->LastPopped = nullptr; + } + + if (OnRestart == OnRestartType::WAIT_UNTIL_FULL && RepeatWhenFilling) + { + if (ch->RemainingRepeatableCount > 0) + { + ch->Ring->ResInterface->OnRepeatPinValue(cpy); + ch->RemainingRepeatableCount--; + return NOS_RESULT_SUCCESS; + } + } + else if (Mode == RingMode::FILL) + { + std::unique_lock lock(ModeMutex); + if (!ModeCV.wait_for(lock, std::chrono::milliseconds(100), + [this] { return Mode != RingMode::FILL; })) + return NOS_RESULT_PENDING; + } + + ResourceInterface::ResourceBase* slot; + { + ScopedProfilerEvent _({.Name = "Wait For Filled Slot"}); + slot = ch->Ring->BeginPop(100); + } + if (!slot) + return ch->Ring->Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; + + nos::Buffer outPinVal; + bool changePinValue = ch->Ring->ResInterface->BeginCopyFrom(slot, *cpy->PinData, outPinVal); + if (changePinValue) + nosEngine.SetPinValueByName(NodeId, ch->OutputName, outPinVal); + + ch->Ring->ResInterface->WaitForDownloadToEnd(slot, "MultiRingBuffer", NodeName.AsString(), cpy); + + cpy->CopyFromOptions.ShouldSetSourceFrameNumber = true; + cpy->FrameNumber = slot->FrameNumber; + + ch->LastPopped = slot; + SendScheduleRequest(1); + return NOS_RESULT_SUCCESS; + } + + void OnEndFrame(uuid const& pinId, nosEndFrameCause cause) override + { + auto* ch = GetChannelByPinId(pinId); + if (!ch) + return; + + if (cause == NOS_END_FRAME_FAILED) + { + if (pinId == ch->OutputId) + return; + if (!ch->IsOutLive) + return; + ChangePinLiveness(ch->OutputName, false); + ch->IsOutLive = false; + } + // Note: EndPop happens at the start of the next CopyFrom for this + // channel rather than here, because OnEndFrame is unreliable for + // secondary live outputs. + } + + void SendScheduleRequest(uint32_t count, bool reset = false) const + { + nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = count, .Reset = reset}; + nosEngine.ScheduleNode(&schedule); + } + + void OnPathCommand(const nosPathCommand* command) override + { + switch (command->Event) + { + case NOS_RING_SIZE_CHANGE: + if (command->RingSize == 0) + return; + RequestedRingSize = command->RingSize; + nosEngine.SetPinValue(*GetPinId(NSN_Size), nos::Buffer::From(command->RingSize)); + break; + default: return; + } + } + + void OnPathStop() override + { + if (OnRestart == OnRestartType::WAIT_UNTIL_FULL) + Mode = RingMode::FILL; + for (auto& [_, ch] : Channels) + { + if (ch->LastPopped && ch->Ring) + { + ch->Ring->EndPop(ch->LastPopped); + ch->LastPopped = nullptr; + } + if (ch->Ring) + ch->Ring->Stop(); + } + } + + void OnPathStart() override + { + if (Channels.empty()) + return; + size_t totalSchedule = 0; + for (auto& [_, ch] : Channels) + { + if (!ch->Ring) + continue; + if (OnRestart == OnRestartType::RESET || RepeatWhenFilling) + ch->Ring->Reset(false); + else if (ch->Ring->IsFull() && !ch->Ring->Read.Pool.empty()) + { + ch->Ring->Write.Pool.push_back(ch->Ring->Read.Pool.front()); + ch->Ring->Read.Pool.pop_front(); + } + if (RequestedRingSize) + { + ch->Ring->Resize(*RequestedRingSize); + ch->NeedsRecreation = false; + } + if (ch->NeedsRecreation) + { + ch->Ring = std::make_unique(ch->Ring->Size, ch->Ring->ResInterface); + ch->NeedsRecreation = false; + } + if (!ch->Ring->IsResourcesValid()) + { + totalSchedule = std::max(totalSchedule, 1); + continue; + } + auto emptySlotCount = ch->Ring->Write.Pool.size(); + if (RepeatWhenFilling) + ch->RemainingRepeatableCount = std::max(emptySlotCount, (size_t)1) - 1; + totalSchedule = std::max(totalSchedule, emptySlotCount); + ch->Ring->Exit = false; + ch->Ring->ResInterface->OnPathStart(); + SeedOutputPin(*ch); + } + RequestedRingSize = std::nullopt; + if (totalSchedule > 0) + { + nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = (uint32_t)totalSchedule}; + nosEngine.ScheduleNode(&schedule); + } + } + + void OnNodeMenuRequested(nosContextMenuRequestPtr request) override + { + flatbuffers::FlatBufferBuilder fbb; + std::vector items = { + nos::CreateContextMenuItemDirect(fbb, "Add Channel", MenuCommand(ADD_CHANNEL, 0))}; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnPinMenuRequested(nos::Name pinName, nosContextMenuRequestPtr request) override + { + auto sv = pinName.AsString(); + if (!IsInputPin(sv) && !IsOutputPin(sv)) + return; + auto letter = ParseLetter(sv); + if (!letter) + return; + if (Channels.size() <= 1) + return; + flatbuffers::FlatBufferBuilder fbb; + std::vector items = {nos::CreateContextMenuItemDirect( + fbb, "Remove Channel", MenuCommand(REMOVE_CHANNEL, static_cast(*letter)))}; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnMenuCommand(uuid const& itemID, uint32_t cmd) override + { + auto command = MenuCommand(cmd); + switch (command.Type) + { + case ADD_CHANNEL: + { + char newLetter = 0; + for (char c : CHANNEL_LETTERS) + { + if (!Channels.contains(c)) + { + newLetter = c; + break; + } + } + if (newLetter == 0) + { + SetNodeStatusMessage("Maximum number of channels reached", fb::NodeStatusMessageType::WARNING); + return; + } + + fb::TPin inPin; + inPin.id = uuid(nosEngine.GenerateID()); + inPin.name = std::string("Input_") + newLetter; + inPin.type_name = "nos.Generic"; + inPin.show_as = fb::ShowAs::INPUT_PIN; + inPin.can_show_as = fb::CanShowAs::INPUT_PIN_ONLY; + + fb::TPin outPin; + outPin.id = uuid(nosEngine.GenerateID()); + outPin.name = std::string("Output_") + newLetter; + outPin.type_name = "nos.Generic"; + outPin.show_as = fb::ShowAs::OUTPUT_PIN; + outPin.can_show_as = fb::CanShowAs::OUTPUT_PIN_ONLY; + outPin.live = true; + + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_add.emplace_back(std::make_unique(std::move(inPin))); + update.pins_to_add.emplace_back(std::make_unique(std::move(outPin))); + flatbuffers::FlatBufferBuilder fbb; + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + break; + } + case REMOVE_CHANNEL: + { + char letter = static_cast(command.Letter); + auto it = Channels.find(letter); + if (it == Channels.end()) + return; + auto& ch = *it->second; + nos::TPartialNodeUpdate update; + update.node_id = NodeId; + update.pins_to_delete = {ch.InputId, ch.OutputId}; + flatbuffers::FlatBufferBuilder fbb; + HandleEvent(CreateAppEvent(fbb, nos::CreatePartialNodeUpdate(fbb, &update))); + break; + } + } + } +}; + +nosResult RegisterMultiRingBuffer(nosNodeFunctions* functions) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("MultiRingBuffer"), MultiRingBufferNodeContext, functions) + return NOS_RESULT_SUCCESS; +} + +} // namespace nos::utilities diff --git a/Plugins/nosUtilities/Source/UtilitiesMain.cpp b/Plugins/nosUtilities/Source/UtilitiesMain.cpp index b3fcc186..b0d8427d 100644 --- a/Plugins/nosUtilities/Source/UtilitiesMain.cpp +++ b/Plugins/nosUtilities/Source/UtilitiesMain.cpp @@ -41,7 +41,9 @@ enum Utilities : int PropagateExecution, UploadBufferProvider, BoundedQueue, + MultiBoundedQueue, RingBuffer, + MultiRingBuffer, Host, DeinterlacedBoundedTextureQueue, DeinterlacedBufferRing, @@ -76,7 +78,9 @@ nosResult RegisterSink(nosNodeFunctions*); nosResult RegisterPropagateExecution(nosNodeFunctions*); nosResult RegisterUploadBufferProvider(nosNodeFunctions*); nosResult RegisterBoundedQueue(nosNodeFunctions*); +nosResult RegisterMultiBoundedQueue(nosNodeFunctions*); nosResult RegisterRingBuffer(nosNodeFunctions*); +nosResult RegisterMultiRingBuffer(nosNodeFunctions*); nosResult RegisterHost(nosNodeFunctions*); nosResult RegisterPin2Json(nosNodeFunctions*); nosResult RegisterJson2Pin(nosNodeFunctions*); @@ -131,7 +135,9 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou GEN_CASE_NODE(PropagateExecution) GEN_CASE_NODE(UploadBufferProvider) GEN_CASE_NODE(BoundedQueue) + GEN_CASE_NODE(MultiBoundedQueue) GEN_CASE_NODE(RingBuffer) + GEN_CASE_NODE(MultiRingBuffer) GEN_CASE_NODE(Host) GEN_CASE_NODE(DeinterlacedBoundedTextureQueue) GEN_CASE_NODE(DeinterlacedBufferRing) diff --git a/Plugins/nosUtilities/Utilities.noscfg b/Plugins/nosUtilities/Utilities.noscfg index 0320cd0d..b83efd55 100644 --- a/Plugins/nosUtilities/Utilities.noscfg +++ b/Plugins/nosUtilities/Utilities.noscfg @@ -43,7 +43,9 @@ "Config/UploadBufferProvider.nosdef", "Config/TimedFunctionSignaller.nosdef", "Config/RingBuffer.nosdef", + "Config/MultiRingBuffer.nosdef", "Config/BoundedQueue.nosdef", + "Config/MultiBoundedQueue.nosdef", "Config/Host.nosdef", "Config/AutoResize.nosdef", "Config/ExecDepend.nosdef", From 3d10d10370cfa0fe3906ff3fe8819190bea62ed8 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 28 Apr 2026 14:32:29 +0300 Subject: [PATCH 11/30] Multi Ring/Queue: Use batch pushing instead of subsequent locks --- .../nosUtilities/Source/MultiBoundedQueue.cpp | 199 +++++++------- Plugins/nosUtilities/Source/MultiRing.h | 256 ++++++++++++++++++ .../nosUtilities/Source/MultiRingBuffer.cpp | 234 ++++++++-------- 3 files changed, 467 insertions(+), 222 deletions(-) create mode 100644 Plugins/nosUtilities/Source/MultiRing.h diff --git a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp index 4b7175a4..3f6eab32 100644 --- a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp +++ b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp @@ -8,6 +8,7 @@ #include #include +#include "MultiRing.h" #include "Ring.h" #include "nosUtil/Stopwatch.hpp" @@ -16,9 +17,6 @@ namespace nos::utilities struct MultiBoundedQueueNodeContext : NodeContext { - using RingMode = RingNodeBase::RingMode; - using OnRestartType = RingNodeBase::OnRestartType; - static constexpr std::string_view CHANNEL_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; enum MenuCommandType : uint8_t @@ -48,7 +46,7 @@ struct MultiBoundedQueueNodeContext : NodeContext uuid InputId{}; uuid OutputId{}; nos::TypeInfo TypeInfo; - std::unique_ptr Ring; + MultiRing::Channel* RingChannel = nullptr; std::atomic_bool IsOutLive = false; bool NeedsRecreation = false; @@ -63,6 +61,7 @@ struct MultiBoundedQueueNodeContext : NodeContext std::map> Channels; std::unordered_map PinIdToLetter; + MultiRing Ring; std::optional RequestedRingSize = std::nullopt; @@ -129,26 +128,24 @@ struct MultiBoundedQueueNodeContext : NodeContext RequestRingResize(size); }); AddPinValueWatcher(NSN_Alignment, [this](nos::Buffer const& newAlignment, std::optional oldVal) { + bool any = false; for (auto& [_, ch] : Channels) { - if (!ch->Ring) + if (!ch->RingChannel) continue; - if (ch->Ring->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) + if (ch->RingChannel->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) { nosEngine.SendPathRestart(ch->InputId); - ch->Ring->Stop(); ch->NeedsRecreation = true; + any = true; } } + if (any) + Ring.Stop(); }); } - ~MultiBoundedQueueNodeContext() override - { - for (auto& [_, ch] : Channels) - if (ch->Ring) - ch->Ring->Stop(); - } + ~MultiBoundedQueueNodeContext() override { Ring.Stop(); } void InitChannel(Channel& ch) { @@ -160,8 +157,7 @@ struct MultiBoundedQueueNodeContext : NodeContext else resource = std::make_shared(); - ch.Ring = std::make_unique(1, std::move(resource)); - ch.Ring->Stop(); + ch.RingChannel = &Ring.AddChannel(ch.Letter, std::move(resource), &ch); } Channel* GetChannelByPinId(uuid const& id) @@ -180,24 +176,18 @@ struct MultiBoundedQueueNodeContext : NodeContext nosEngine.LogW((GetName() + " size cannot be 0").c_str()); return; } - bool changed = false; + if (Ring.Size == size && (!RequestedRingSize.has_value() || *RequestedRingSize == size)) + return; for (auto& [_, ch] : Channels) { - if (!ch->Ring) + if (!ch->RingChannel) continue; - if (ch->Ring->Size != size && (!RequestedRingSize.has_value() || *RequestedRingSize != size)) - { - nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; - nosEngine.SendPathCommand(ch->InputId, ringSizeChange); - ch->Ring->Stop(); - changed = true; - } - } - if (changed) - { - SendPathRestart(); - RequestedRingSize = size; + nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; + nosEngine.SendPathCommand(ch->InputId, ringSizeChange); } + Ring.Stop(); + SendPathRestart(); + RequestedRingSize = size; } void SendPathRestart() @@ -212,12 +202,12 @@ struct MultiBoundedQueueNodeContext : NodeContext if (!IsInputPin(sv)) return; auto* ch = GetChannelByPinId(pinId); - if (!ch || !ch->Ring) + if (!ch || !ch->RingChannel) return; - if (ch->Ring->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) + if (ch->RingChannel->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) { nosEngine.SendPathRestart(ch->InputId); - ch->Ring->Stop(); + Ring.Stop(); ch->NeedsRecreation = true; } } @@ -235,9 +225,12 @@ struct MultiBoundedQueueNodeContext : NodeContext if (ch.TypeInfo->TypeName != NSN_Generic) return NOS_RESULT_FAILED; ch.TypeInfo = nos::TypeInfo(params->IncomingTypeName); - if (ch.Ring) - ch.Ring->Stop(); - ch.Ring.reset(); + if (ch.RingChannel) + { + Ring.Stop(); + Ring.RemoveChannel(*letter); + ch.RingChannel = nullptr; + } for (size_t i = 0; i < params->PinCount; i++) { auto& pinInfo = params->Pins[i]; @@ -250,7 +243,7 @@ struct MultiBoundedQueueNodeContext : NodeContext void OnPinUpdated(const nosPinUpdate*) override { for (auto& [_, ch] : Channels) - if (!ch->Ring) + if (!ch->RingChannel) InitChannel(*ch); } @@ -271,8 +264,11 @@ struct MultiBoundedQueueNodeContext : NodeContext bool outputAlive = PinIdToLetter.contains(ch.OutputId); if (!inputAlive && !outputAlive) { - if (ch.Ring) - ch.Ring->Stop(); + if (ch.RingChannel) + { + Ring.RemoveChannel(letter); + ch.RingChannel = nullptr; + } Channels.erase(chIt); } } @@ -296,92 +292,84 @@ struct MultiBoundedQueueNodeContext : NodeContext chPtr->IsOutLive = pin->live(); } PinIdToLetter[uuid(*pin->id())] = *letter; - if (!chPtr->Ring) + if (!chPtr->RingChannel) InitChannel(*chPtr); } } nosResult ExecuteNode(nosNodeExecuteParams* params) override { - if (Channels.empty()) + if (Channels.empty() || Ring.Exit) return NOS_RESULT_FAILED; NodeExecuteParams pins(params); uint32_t requestedSize = *pins.GetPinData(NSN_Size); - std::vector> inputs; - inputs.reserve(Channels.size()); + struct Gathered + { + Channel* NodeCh; + MultiRing::Channel* RingCh; + void* Input; + }; + std::vector gathered; + gathered.reserve(Channels.size()); + std::vector wantedRings; + wantedRings.reserve(Channels.size()); + uint32_t maxRequired = requestedSize; for (auto& [_, ch] : Channels) { - if (!ch->Ring || ch->Ring->Exit || !ch->Ring->IsResourcesValid() || !ch->TypeInfo) + if (!ch->RingChannel || ch->RingChannel->Resources.empty() || !ch->TypeInfo) continue; auto it = pins.find(ch->InputName); if (it == pins.end()) continue; - void* input = ch->Ring->ResInterface->GetPinInfo(it->second, false); + void* input = ch->RingChannel->ResInterface->GetPinInfo(it->second, false); if (!input) continue; - auto [required, _] = ch->Ring->ResInterface->GetRequiredRingSize(input, requestedSize); + auto [required, _] = ch->RingChannel->ResInterface->GetRequiredRingSize(input, requestedSize); if (required > maxRequired) maxRequired = required; - inputs.emplace_back(ch.get(), input); + gathered.push_back({ch.get(), ch->RingChannel, input}); + wantedRings.push_back(ch->RingChannel); } - if (inputs.empty()) + if (gathered.empty()) { SendScheduleRequest(0); return NOS_RESULT_FAILED; } - bool anyResize = false; - for (auto& [ch, _] : inputs) - if (ch->Ring->Size != maxRequired) - { - anyResize = true; - break; - } - if (anyResize) + if (Ring.Size != maxRequired) { RequestRingResize(maxRequired); return NOS_RESULT_FAILED; } - std::vector slots(inputs.size(), nullptr); - for (size_t i = 0; i < inputs.size(); ++i) - { - auto* ch = inputs[i].first; - auto* slot = ch->Ring->BeginPush(100); - if (!slot) - { - for (size_t j = 0; j < i; ++j) - inputs[j].first->Ring->CancelPush(slots[j]); - if (ch->Ring->Exit) - return NOS_RESULT_FAILED; - return NOS_RESULT_PENDING; - } - slots[i] = slot; - } + std::vector slots; + if (!Ring.BeginPushSubset(100, wantedRings, slots)) + return Ring.Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; - for (size_t i = 0; i < inputs.size(); ++i) + for (size_t i = 0; i < gathered.size(); ++i) { - auto* ch = inputs[i].first; - ch->Ring->ResInterface->Push(slots[i], inputs[i].second, params, + auto& g = gathered[i]; + auto* slot = slots[i].second; + g.RingCh->ResInterface->Push(slot, g.Input, params, NOS_NAME_STATIC("MultiBoundedQueue"), false); - ch->Ring->EndPush(slots[i]); - if (!ch->IsOutLive) + if (!g.NodeCh->IsOutLive) { - ChangePinLiveness(ch->OutputName, true); - ch->IsOutLive = true; + ChangePinLiveness(g.NodeCh->OutputName, true); + g.NodeCh->IsOutLive = true; } } + Ring.EndPushAll(slots); return NOS_RESULT_SUCCESS; } nosResult CopyFrom(nosCopyInfo* cpy) override { auto* ch = GetChannelByPinId(cpy->ID); - if (!ch || !ch->Ring || ch->Ring->Exit) + if (!ch || !ch->RingChannel || Ring.Exit) return NOS_RESULT_FAILED; if (!ch->IsOutLive) return NOS_RESULT_SUCCESS; @@ -389,17 +377,17 @@ struct MultiBoundedQueueNodeContext : NodeContext ResourceInterface::ResourceBase* slot; { ScopedProfilerEvent _({.Name = "Wait For Filled Slot"}); - slot = ch->Ring->BeginPop(100); + slot = Ring.BeginPop(*ch->RingChannel, 100); } if (!slot) - return ch->Ring->Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; + return Ring.Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; - ch->Ring->ResInterface->Copy(slot, cpy, NodeId); + ch->RingChannel->ResInterface->Copy(slot, cpy, NodeId); cpy->CopyFromOptions.ShouldSetSourceFrameNumber = true; cpy->FrameNumber = slot->FrameNumber; - ch->Ring->EndPop(slot); + Ring.EndPop(*ch->RingChannel, slot); SendScheduleRequest(1); return NOS_RESULT_SUCCESS; } @@ -439,45 +427,46 @@ struct MultiBoundedQueueNodeContext : NodeContext } } - void OnPathStop() override - { - for (auto& [_, ch] : Channels) - if (ch->Ring) - ch->Ring->Stop(); - } + void OnPathStop() override { Ring.Stop(); } void OnPathStart() override { if (Channels.empty()) return; - size_t totalSchedule = 0; - for (auto& [_, ch] : Channels) + + Ring.ResetAll(false); + + if (RequestedRingSize) { - if (!ch->Ring) - continue; - // FIFO restart: drop any frames left from the previous run. - ch->Ring->Reset(false); - if (RequestedRingSize) - { - ch->Ring->Resize(*RequestedRingSize); + Ring.ResizeAll(*RequestedRingSize); + for (auto& [_, ch] : Channels) ch->NeedsRecreation = false; - } - if (ch->NeedsRecreation) + RequestedRingSize = std::nullopt; + } + for (auto& [_, ch] : Channels) + { + if (ch->NeedsRecreation && ch->RingChannel) { - ch->Ring = std::make_unique(ch->Ring->Size, ch->Ring->ResInterface); + Ring.RecreateChannelResources(*ch->RingChannel); ch->NeedsRecreation = false; } - if (!ch->Ring->IsResourcesValid()) + } + + size_t totalSchedule = 0; + for (auto& [_, ch] : Channels) + { + if (!ch->RingChannel) + continue; + if (ch->RingChannel->Resources.empty()) { totalSchedule = std::max(totalSchedule, 1); continue; } - auto emptySlotCount = ch->Ring->Write.Pool.size(); + auto emptySlotCount = Ring.WritePoolSize(*ch->RingChannel); totalSchedule = std::max(totalSchedule, emptySlotCount); - ch->Ring->Exit = false; - ch->Ring->ResInterface->OnPathStart(); + ch->RingChannel->ResInterface->OnPathStart(); } - RequestedRingSize = std::nullopt; + Ring.Start(); if (totalSchedule > 0) { nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = (uint32_t)totalSchedule}; diff --git a/Plugins/nosUtilities/Source/MultiRing.h b/Plugins/nosUtilities/Source/MultiRing.h new file mode 100644 index 00000000..c2d0ba14 --- /dev/null +++ b/Plugins/nosUtilities/Source/MultiRing.h @@ -0,0 +1,256 @@ +/* + * Copyright MediaZ Teknoloji A.S. All Rights Reserved. + */ + +#pragma once + +#include "Ring.h" + +namespace nos +{ + +// Ring that holds N independent channels under a single mutex / CV pair. +// Each channel still owns its own slot pools and Resources, but every push, +// pop, resize and reset goes through the shared synchronization, so an +// N-channel batch push is a single lock acquisition, not N. +struct MultiRing +{ + struct Channel + { + std::shared_ptr ResInterface; + std::vector> Resources; + std::deque WritePool; + std::deque ReadPool; + void* UserData = nullptr; + }; + + std::map> Channels; + std::mutex Mutex; + std::condition_variable WriteCV; + std::condition_variable ReadCV; + std::atomic_bool Exit = true; + uint32_t Size = 0; + + ~MultiRing() { Stop(); } + + void Stop() + { + { + std::unique_lock lock(Mutex); + Exit = true; + } + WriteCV.notify_all(); + ReadCV.notify_all(); + } + + void Start() + { + std::unique_lock lock(Mutex); + Exit = false; + } + + void AllocateChannelResourcesUnlocked(Channel& ch) + { + ch.WritePool.clear(); + ch.ReadPool.clear(); + ch.Resources.clear(); + for (uint32_t i = 0; i < Size; ++i) + { + auto res = ch.ResInterface->CreateResource(); + if (!res) + { + nosEngine.LogE("Failed to create resource for multi ring buffer."); + ch.Resources.clear(); + ch.WritePool.clear(); + ch.ReadPool.clear(); + Exit = true; + return; + } + ch.Resources.push_back(res); + ch.WritePool.push_back(res.get()); + } + } + + Channel& AddChannel(char key, std::shared_ptr resInterface, void* userData = nullptr) + { + std::unique_lock lock(Mutex); + auto& ch = Channels[key]; + if (!ch) + ch = std::make_unique(); + ch->ResInterface = std::move(resInterface); + ch->UserData = userData; + if (Size == 0) + Size = 1; + AllocateChannelResourcesUnlocked(*ch); + return *ch; + } + + void RemoveChannel(char key) + { + std::unique_lock lock(Mutex); + Channels.erase(key); + } + + void RecreateChannelResources(Channel& ch) + { + std::unique_lock lock(Mutex); + AllocateChannelResourcesUnlocked(ch); + } + + void ResizeAll(uint32_t newSize) + { + std::unique_lock lock(Mutex); + Size = newSize; + for (auto& [_, ch] : Channels) + AllocateChannelResourcesUnlocked(*ch); + } + + bool AreAllChannelsValid() + { + std::unique_lock lock(Mutex); + if (Channels.empty()) + return false; + for (auto& [_, ch] : Channels) + if (ch->Resources.empty()) + return false; + return true; + } + + // Move slots between pools for every channel. fill=false: read→write. + void ResetAll(bool fill) + { + std::unique_lock lock(Mutex); + for (auto& [_, ch] : Channels) + { + auto& from = fill ? ch->WritePool : ch->ReadPool; + auto& to = fill ? ch->ReadPool : ch->WritePool; + while (!from.empty()) + { + auto* slot = from.front(); + from.pop_front(); + ch->ResInterface->Reset(slot); + to.push_back(slot); + } + } + } + + // If this channel is full and its read pool is non-empty, hand one slot + // back to the write pool so the producer can start pushing again. + void MoveOneReadToWriteIfFull(Channel& ch) + { + std::unique_lock lock(Mutex); + if (ch.ReadPool.size() != ch.Resources.size() || ch.ReadPool.empty()) + return; + auto* slot = ch.ReadPool.front(); + ch.ReadPool.pop_front(); + ch.WritePool.push_back(slot); + } + + bool IsFull(Channel const& ch) + { + std::unique_lock lock(Mutex); + return ch.ReadPool.size() == ch.Resources.size(); + } + + bool IsEmpty(Channel const& ch) + { + std::unique_lock lock(Mutex); + return ch.ReadPool.empty(); + } + + size_t WritePoolSize(Channel const& ch) + { + std::unique_lock lock(Mutex); + return ch.WritePool.size(); + } + + size_t ReadPoolSize(Channel const& ch) + { + std::unique_lock lock(Mutex); + return ch.ReadPool.size(); + } + + using SlotPair = std::pair; + + // Atomically pop one slot from each requested channel's WritePool. + // Waits until every requested channel has at least one slot, or + // timeout/exit. The caller-supplied list typically excludes channels + // that don't have valid input data this frame. + bool BeginPushSubset(uint64_t timeoutMs, + std::vector const& wanted, + std::vector& outSlots) + { + std::unique_lock lock(Mutex); + auto pred = [&] { + if (Exit) + return true; + if (wanted.empty()) + return false; + for (auto* ch : wanted) + if (ch->WritePool.empty()) + return false; + return true; + }; + if (!WriteCV.wait_for(lock, std::chrono::milliseconds(timeoutMs), pred)) + return false; + if (Exit) + return false; + outSlots.clear(); + outSlots.reserve(wanted.size()); + for (auto* ch : wanted) + { + auto* slot = ch->WritePool.front(); + ch->WritePool.pop_front(); + outSlots.emplace_back(ch, slot); + } + return true; + } + + void EndPushAll(std::vector const& slots) + { + { + std::unique_lock lock(Mutex); + for (auto& [ch, slot] : slots) + ch->ReadPool.push_back(slot); + } + ReadCV.notify_all(); + } + + void CancelPushAll(std::vector const& slots) + { + { + std::unique_lock lock(Mutex); + for (auto& [ch, slot] : slots) + { + slot->FrameNumber = 0; + ch->WritePool.push_front(slot); + } + } + WriteCV.notify_all(); + } + + ResourceInterface::ResourceBase* BeginPop(Channel& ch, uint64_t timeoutMs) + { + std::unique_lock lock(Mutex); + if (!ReadCV.wait_for(lock, std::chrono::milliseconds(timeoutMs), + [&] { return !ch.ReadPool.empty() || Exit; })) + return nullptr; + if (Exit) + return nullptr; + auto* slot = ch.ReadPool.front(); + ch.ReadPool.pop_front(); + return slot; + } + + void EndPop(Channel& ch, ResourceInterface::ResourceBase* slot) + { + { + std::unique_lock lock(Mutex); + slot->FrameNumber = 0; + ch.WritePool.push_back(slot); + } + WriteCV.notify_all(); + } +}; + +} // namespace nos diff --git a/Plugins/nosUtilities/Source/MultiRingBuffer.cpp b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp index a13c7f09..54304f41 100644 --- a/Plugins/nosUtilities/Source/MultiRingBuffer.cpp +++ b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp @@ -8,6 +8,7 @@ #include #include +#include "MultiRing.h" #include "Ring.h" #include "nosUtil/Stopwatch.hpp" @@ -48,7 +49,7 @@ struct MultiRingBufferNodeContext : NodeContext uuid InputId{}; uuid OutputId{}; nos::TypeInfo TypeInfo; - std::unique_ptr Ring; + MultiRing::Channel* RingChannel = nullptr; std::atomic_bool IsOutLive = false; ResourceInterface::ResourceBase* LastPopped = nullptr; bool NeedsRecreation = false; @@ -65,6 +66,7 @@ struct MultiRingBufferNodeContext : NodeContext std::map> Channels; std::unordered_map PinIdToLetter; + MultiRing Ring; OnRestartType OnRestart = OnRestartType::WAIT_UNTIL_FULL; std::optional RequestedRingSize = std::nullopt; @@ -136,17 +138,20 @@ struct MultiRingBufferNodeContext : NodeContext RequestRingResize(size); }); AddPinValueWatcher(NSN_Alignment, [this](nos::Buffer const& newAlignment, std::optional oldVal) { + bool any = false; for (auto& [_, ch] : Channels) { - if (!ch->Ring) + if (!ch->RingChannel) continue; - if (ch->Ring->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) + if (ch->RingChannel->ResInterface->CheckNewResource(NSN_Alignment, newAlignment, oldVal)) { nosEngine.SendPathRestart(ch->InputId); - ch->Ring->Stop(); ch->NeedsRecreation = true; + any = true; } } + if (any) + Ring.Stop(); }); AddPinValueWatcher(NOS_NAME_STATIC("RepeatWhenFilling"), [this](nos::Buffer const& newVal, std::optional oldVal) { @@ -157,11 +162,8 @@ struct MultiRingBufferNodeContext : NodeContext ~MultiRingBufferNodeContext() override { for (auto& [_, ch] : Channels) - { NOS_SOFT_CHECK(ch->LastPopped == nullptr); - if (ch->Ring) - ch->Ring->Stop(); - } + Ring.Stop(); } void InitChannel(Channel& ch) @@ -174,8 +176,7 @@ struct MultiRingBufferNodeContext : NodeContext else resource = std::make_shared(); - ch.Ring = std::make_unique(1, std::move(resource)); - ch.Ring->Stop(); + ch.RingChannel = &Ring.AddChannel(ch.Letter, std::move(resource), &ch); } Channel* GetChannelByPinId(uuid const& id) @@ -189,9 +190,9 @@ struct MultiRingBufferNodeContext : NodeContext void SeedOutputPin(Channel& ch) { - if (!ch.Ring || !ch.Ring->IsResourcesValid()) + if (!ch.RingChannel || ch.RingChannel->Resources.empty()) return; - auto* base = ch.Ring->Resources[0].get(); + auto* base = ch.RingChannel->Resources[0].get(); if (!base) return; if (ch.TypeInfo->TypeName == NOS_NAME(sys::vulkan::Buffer::GetFullyQualifiedName())) @@ -217,24 +218,18 @@ struct MultiRingBufferNodeContext : NodeContext nosEngine.LogW((GetName() + " size cannot be 0").c_str()); return; } - bool changed = false; + if (Ring.Size == size && (!RequestedRingSize.has_value() || *RequestedRingSize == size)) + return; for (auto& [_, ch] : Channels) { - if (!ch->Ring) + if (!ch->RingChannel) continue; - if (ch->Ring->Size != size && (!RequestedRingSize.has_value() || *RequestedRingSize != size)) - { - nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; - nosEngine.SendPathCommand(ch->InputId, ringSizeChange); - ch->Ring->Stop(); - changed = true; - } - } - if (changed) - { - SendPathRestart(); - RequestedRingSize = size; + nosPathCommand ringSizeChange{.Event = NOS_RING_SIZE_CHANGE, .RingSize = size}; + nosEngine.SendPathCommand(ch->InputId, ringSizeChange); } + Ring.Stop(); + SendPathRestart(); + RequestedRingSize = size; } void SendPathRestart() @@ -249,12 +244,12 @@ struct MultiRingBufferNodeContext : NodeContext if (!IsInputPin(sv)) return; auto* ch = GetChannelByPinId(pinId); - if (!ch || !ch->Ring) + if (!ch || !ch->RingChannel) return; - if (ch->Ring->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) + if (ch->RingChannel->ResInterface->CheckNewResource(NSN_Input, value, std::nullopt)) { nosEngine.SendPathRestart(ch->InputId); - ch->Ring->Stop(); + Ring.Stop(); ch->NeedsRecreation = true; } } @@ -272,10 +267,13 @@ struct MultiRingBufferNodeContext : NodeContext if (ch.TypeInfo->TypeName != NSN_Generic) return NOS_RESULT_FAILED; ch.TypeInfo = nos::TypeInfo(params->IncomingTypeName); - // Drop the Generic-fallback ring so OnPinUpdated re-inits with the resolved type. - if (ch.Ring) - ch.Ring->Stop(); - ch.Ring.reset(); + // Drop the Generic-fallback ring channel so OnPinUpdated re-inits with the resolved type. + if (ch.RingChannel) + { + Ring.Stop(); + Ring.RemoveChannel(*letter); + ch.RingChannel = nullptr; + } for (size_t i = 0; i < params->PinCount; i++) { auto& pinInfo = params->Pins[i]; @@ -288,7 +286,7 @@ struct MultiRingBufferNodeContext : NodeContext void OnPinUpdated(const nosPinUpdate*) override { for (auto& [_, ch] : Channels) - if (!ch->Ring) + if (!ch->RingChannel) InitChannel(*ch); } @@ -309,8 +307,11 @@ struct MultiRingBufferNodeContext : NodeContext bool outputAlive = PinIdToLetter.contains(ch.OutputId); if (!inputAlive && !outputAlive) { - if (ch.Ring) - ch.Ring->Stop(); + if (ch.RingChannel) + { + Ring.RemoveChannel(letter); + ch.RingChannel = nullptr; + } Channels.erase(chIt); } } @@ -334,43 +335,52 @@ struct MultiRingBufferNodeContext : NodeContext chPtr->IsOutLive = pin->live(); } PinIdToLetter[uuid(*pin->id())] = *letter; - if (!chPtr->Ring) + if (!chPtr->RingChannel) InitChannel(*chPtr); } } nosResult ExecuteNode(nosNodeExecuteParams* params) override { - if (Channels.empty()) + if (Channels.empty() || Ring.Exit) return NOS_RESULT_FAILED; + NodeExecuteParams pins(params); uint32_t requestedSize = *pins.GetPinData(NSN_Size); - std::vector> inputs; - inputs.reserve(Channels.size()); + struct Gathered + { + Channel* NodeCh; + MultiRing::Channel* RingCh; + void* Input; + }; + std::vector gathered; + gathered.reserve(Channels.size()); + std::vector wantedRings; + wantedRings.reserve(Channels.size()); + uint32_t maxRequired = requestedSize; std::string adjustMessage; - for (auto& [letter, ch] : Channels) + for (auto& [_, ch] : Channels) { - // Skip channels whose ring isn't ready yet (e.g. just added, - // awaiting OnNodeUpdated/OnPinUpdated to finish init). - if (!ch->Ring || ch->Ring->Exit || !ch->Ring->IsResourcesValid() || !ch->TypeInfo) + if (!ch->RingChannel || ch->RingChannel->Resources.empty() || !ch->TypeInfo) continue; auto it = pins.find(ch->InputName); if (it == pins.end()) continue; - void* input = ch->Ring->ResInterface->GetPinInfo(it->second, true); + void* input = ch->RingChannel->ResInterface->GetPinInfo(it->second, true); if (!input) continue; - auto [required, message] = ch->Ring->ResInterface->GetRequiredRingSize(input, requestedSize); + auto [required, message] = ch->RingChannel->ResInterface->GetRequiredRingSize(input, requestedSize); if (required > maxRequired) { maxRequired = required; adjustMessage = message; } - inputs.emplace_back(ch.get(), input); + gathered.push_back({ch.get(), ch->RingChannel, input}); + wantedRings.push_back(ch->RingChannel); } - if (inputs.empty()) + if (gathered.empty()) { SendScheduleRequest(0); return NOS_RESULT_FAILED; @@ -381,15 +391,7 @@ struct MultiRingBufferNodeContext : NodeContext if (effectiveSizeAdjusted) SetNodeStatusMessage(adjustMessage, fb::NodeStatusMessageType::WARNING); - bool anyResize = false; - for (auto& [ch, _] : inputs) - if (ch->Ring->Size != maxRequired) - { - anyResize = true; - break; - } - - if (anyResize) + if (Ring.Size != maxRequired) { RequestRingResize(maxRequired); if (effectiveSizeAdjusted) @@ -397,40 +399,31 @@ struct MultiRingBufferNodeContext : NodeContext return NOS_RESULT_FAILED; } - std::vector slots(inputs.size(), nullptr); - for (size_t i = 0; i < inputs.size(); ++i) - { - auto* ch = inputs[i].first; - auto* slot = ch->Ring->BeginPush(100); - if (!slot) - { - for (size_t j = 0; j < i; ++j) - inputs[j].first->Ring->CancelPush(slots[j]); - if (ch->Ring->Exit) - return NOS_RESULT_FAILED; - return NOS_RESULT_PENDING; - } - slots[i] = slot; - } + std::vector slots; + if (!Ring.BeginPushSubset(100, wantedRings, slots)) + return Ring.Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; - for (size_t i = 0; i < inputs.size(); ++i) + // Push outside the lock — Vulkan command recording can be slow. + for (size_t i = 0; i < gathered.size(); ++i) { - auto* ch = inputs[i].first; - ch->Ring->ResInterface->Push(slots[i], inputs[i].second, params, + auto& g = gathered[i]; + auto* slot = slots[i].second; + g.RingCh->ResInterface->Push(slot, g.Input, params, NOS_NAME_STATIC("MultiRingBuffer"), true); - ch->Ring->EndPush(slots[i]); - if (!ch->IsOutLive) + if (!g.NodeCh->IsOutLive) { - ChangePinLiveness(ch->OutputName, true); - ch->IsOutLive = true; + ChangePinLiveness(g.NodeCh->OutputName, true); + g.NodeCh->IsOutLive = true; } } + Ring.EndPushAll(slots); + if (Mode == RingMode::FILL) { bool isFillComplete = true; - for (auto& [ch, _] : inputs) - if (ch->Ring->Write.Pool.size() != 0) + for (auto* rc : wantedRings) + if (Ring.WritePoolSize(*rc) != 0) { isFillComplete = false; break; @@ -448,7 +441,7 @@ struct MultiRingBufferNodeContext : NodeContext nosResult CopyFrom(nosCopyInfo* cpy) override { auto* ch = GetChannelByPinId(cpy->ID); - if (!ch || !ch->Ring || ch->Ring->Exit) + if (!ch || !ch->RingChannel || Ring.Exit) return NOS_RESULT_FAILED; if (!ch->IsOutLive) return NOS_RESULT_SUCCESS; @@ -460,7 +453,7 @@ struct MultiRingBufferNodeContext : NodeContext // for the next frame on this pin, it's done with the previous one. if (ch->LastPopped) { - ch->Ring->EndPop(ch->LastPopped); + Ring.EndPop(*ch->RingChannel, ch->LastPopped); ch->LastPopped = nullptr; } @@ -468,7 +461,7 @@ struct MultiRingBufferNodeContext : NodeContext { if (ch->RemainingRepeatableCount > 0) { - ch->Ring->ResInterface->OnRepeatPinValue(cpy); + ch->RingChannel->ResInterface->OnRepeatPinValue(cpy); ch->RemainingRepeatableCount--; return NOS_RESULT_SUCCESS; } @@ -484,17 +477,17 @@ struct MultiRingBufferNodeContext : NodeContext ResourceInterface::ResourceBase* slot; { ScopedProfilerEvent _({.Name = "Wait For Filled Slot"}); - slot = ch->Ring->BeginPop(100); + slot = Ring.BeginPop(*ch->RingChannel, 100); } if (!slot) - return ch->Ring->Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; + return Ring.Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; nos::Buffer outPinVal; - bool changePinValue = ch->Ring->ResInterface->BeginCopyFrom(slot, *cpy->PinData, outPinVal); + bool changePinValue = ch->RingChannel->ResInterface->BeginCopyFrom(slot, *cpy->PinData, outPinVal); if (changePinValue) nosEngine.SetPinValueByName(NodeId, ch->OutputName, outPinVal); - ch->Ring->ResInterface->WaitForDownloadToEnd(slot, "MultiRingBuffer", NodeName.AsString(), cpy); + ch->RingChannel->ResInterface->WaitForDownloadToEnd(slot, "MultiRingBuffer", NodeName.AsString(), cpy); cpy->CopyFromOptions.ShouldSetSourceFrameNumber = true; cpy->FrameNumber = slot->FrameNumber; @@ -519,9 +512,9 @@ struct MultiRingBufferNodeContext : NodeContext ChangePinLiveness(ch->OutputName, false); ch->IsOutLive = false; } - // Note: EndPop happens at the start of the next CopyFrom for this - // channel rather than here, because OnEndFrame is unreliable for - // secondary live outputs. + // EndPop happens at the start of the next CopyFrom for this channel + // rather than here, because OnEndFrame is unreliable for secondary + // live outputs. } void SendScheduleRequest(uint32_t count, bool reset = false) const @@ -550,56 +543,63 @@ struct MultiRingBufferNodeContext : NodeContext Mode = RingMode::FILL; for (auto& [_, ch] : Channels) { - if (ch->LastPopped && ch->Ring) + if (ch->LastPopped && ch->RingChannel) { - ch->Ring->EndPop(ch->LastPopped); + Ring.EndPop(*ch->RingChannel, ch->LastPopped); ch->LastPopped = nullptr; } - if (ch->Ring) - ch->Ring->Stop(); } + Ring.Stop(); } void OnPathStart() override { if (Channels.empty()) return; - size_t totalSchedule = 0; - for (auto& [_, ch] : Channels) + + if (OnRestart == OnRestartType::RESET || RepeatWhenFilling) + Ring.ResetAll(false); + else { - if (!ch->Ring) - continue; - if (OnRestart == OnRestartType::RESET || RepeatWhenFilling) - ch->Ring->Reset(false); - else if (ch->Ring->IsFull() && !ch->Ring->Read.Pool.empty()) - { - ch->Ring->Write.Pool.push_back(ch->Ring->Read.Pool.front()); - ch->Ring->Read.Pool.pop_front(); - } - if (RequestedRingSize) - { - ch->Ring->Resize(*RequestedRingSize); + for (auto& [_, ch] : Channels) + if (ch->RingChannel) + Ring.MoveOneReadToWriteIfFull(*ch->RingChannel); + } + + if (RequestedRingSize) + { + Ring.ResizeAll(*RequestedRingSize); + for (auto& [_, ch] : Channels) ch->NeedsRecreation = false; - } - if (ch->NeedsRecreation) + RequestedRingSize = std::nullopt; + } + for (auto& [_, ch] : Channels) + { + if (ch->NeedsRecreation && ch->RingChannel) { - ch->Ring = std::make_unique(ch->Ring->Size, ch->Ring->ResInterface); + Ring.RecreateChannelResources(*ch->RingChannel); ch->NeedsRecreation = false; } - if (!ch->Ring->IsResourcesValid()) + } + + size_t totalSchedule = 0; + for (auto& [_, ch] : Channels) + { + if (!ch->RingChannel) + continue; + if (ch->RingChannel->Resources.empty()) { totalSchedule = std::max(totalSchedule, 1); continue; } - auto emptySlotCount = ch->Ring->Write.Pool.size(); + auto emptySlotCount = Ring.WritePoolSize(*ch->RingChannel); if (RepeatWhenFilling) ch->RemainingRepeatableCount = std::max(emptySlotCount, (size_t)1) - 1; totalSchedule = std::max(totalSchedule, emptySlotCount); - ch->Ring->Exit = false; - ch->Ring->ResInterface->OnPathStart(); + ch->RingChannel->ResInterface->OnPathStart(); SeedOutputPin(*ch); } - RequestedRingSize = std::nullopt; + Ring.Start(); if (totalSchedule > 0) { nosScheduleNodeParams schedule{.NodeId = NodeId, .AddScheduleCount = (uint32_t)totalSchedule}; From 73a98816aa328f67cb7255ae588e36f1a87fd88e Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 29 Apr 2026 14:44:11 +0300 Subject: [PATCH 12/30] Add timecode based indexing to playback track node --- Plugins/nosTrack/CMakeLists.txt | 3 + Plugins/nosTrack/Config/PlaybackMode.fbs | 9 ++ .../Config/PlaybackTrackCOLMAP.nosdef | 28 ++++- .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 17 +++ .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 111 +++++++++++++++++- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 37 +++++- Plugins/nosTrack/Track.noscfg | 3 + 7 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 Plugins/nosTrack/Config/PlaybackMode.fbs diff --git a/Plugins/nosTrack/CMakeLists.txt b/Plugins/nosTrack/CMakeLists.txt index 26c05e2b..2b30615c 100644 --- a/Plugins/nosTrack/CMakeLists.txt +++ b/Plugins/nosTrack/CMakeLists.txt @@ -13,4 +13,7 @@ endforeach() list(APPEND MODULE_DEPENDENCIES_TARGETS ${NOS_PLUGIN_SDK_TARGET}) +nos_generate_flatbuffers("${CMAKE_CURRENT_SOURCE_DIR}/Config" "${CMAKE_CURRENT_SOURCE_DIR}/Source" "cpp" "" nosTrack_generated) +list(APPEND MODULE_DEPENDENCIES_TARGETS nosTrack_generated) + nos_add_plugin("nosTrack" "${MODULE_DEPENDENCIES_TARGETS}" "${CMAKE_CURRENT_LIST_DIR}/External/asio/asio/include") diff --git a/Plugins/nosTrack/Config/PlaybackMode.fbs b/Plugins/nosTrack/Config/PlaybackMode.fbs new file mode 100644 index 00000000..c0d1d952 --- /dev/null +++ b/Plugins/nosTrack/Config/PlaybackMode.fbs @@ -0,0 +1,9 @@ +namespace nos.track; + +// Selects how PlaybackTrackCOLMAP indexes into the recorded frames. +enum PlaybackTrackMode : uint +{ + FrameIndex = 0, // Use the InFrameIndex pin as a 0-based offset. + Timecode = 1, // Look up by Timecode string from timecodes.txt. + FrameNumber = 2, // Look up by FrameNumber column from timecodes.txt. +} diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef index 67b8f74e..54cb1cdc 100644 --- a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -32,6 +32,15 @@ "data": "ZYX", "description": "Euler angle rotation order for converting COLMAP quaternion to Track rotation. Default ZYX matches the FreeD node convention." }, + { + "name": "Mode", + "display_name": "Mode", + "type_name": "nos.track.PlaybackTrackMode", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "FrameIndex", + "description": "Selects how to index into the loaded frames. FrameIndex uses InFrameIndex; Timecode/FrameNumber look up via the timecodes.txt sidecar. The unused index pin is set to PASSIVE." + }, { "name": "InFrameIndex", "display_name": "Frame Index", @@ -39,7 +48,24 @@ "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": 0, - "description": "Frame index to output." + "description": "0-based frame index. Used when Mode=FrameIndex." + }, + { + "name": "InTimecode", + "display_name": "Timecode", + "type_name": "string", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "description": "Timecode string (HH:MM:SS:FF or HH:MM:SS;FF) to look up. Used when Mode=Timecode. Requires a timecodes.txt sidecar." + }, + { + "name": "InFrameNumber", + "display_name": "Frame Number", + "type_name": "uint", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0, + "description": "Absolute frame number to look up. Used when Mode=FrameNumber. Requires a timecodes.txt sidecar." }, { "name": "Track", diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 2237b720..45342e37 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -22,6 +22,23 @@ "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": "Timecode", + "display_name": "Timecode", + "type_name": "string", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "description": "Optional SMPTE timecode (HH:MM:SS:FF or HH:MM:SS;FF) for the current frame. Written to a timecodes.txt sidecar when non-empty." + }, + { + "name": "FrameNumber", + "display_name": "Frame Number", + "type_name": "uint", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0, + "description": "Optional absolute frame number that pairs with Timecode (e.g. from ExtractTimecode). Written to the timecodes.txt sidecar." + }, { "name": "OutTrack", "display_name": "Track", diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp index 567f929b..79f629f1 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -2,6 +2,7 @@ #include #include "nosSysTrack/Track_generated.h" +#include "PlaybackMode_generated.h" #include #include @@ -14,13 +15,17 @@ #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_Mode, "Mode"); NOS_REGISTER_NAME_SPACED(Playback_InFrameIndex, "InFrameIndex"); +NOS_REGISTER_NAME_SPACED(Playback_InTimecode, "InTimecode"); +NOS_REGISTER_NAME_SPACED(Playback_InFrameNumber, "InFrameNumber"); NOS_REGISTER_NAME_SPACED(Playback_OutFrameIndex, "OutFrameIndex"); NOS_REGISTER_NAME_SPACED(Playback_FrameCount, "FrameCount"); @@ -44,14 +49,27 @@ struct COLMAPImage uint32_t CameraId = 0; }; +struct TimecodeEntry +{ + std::string Timecode; + uint32_t FrameNumber = 0; +}; + struct PlaybackTrackCOLMAPContext : NodeContext { std::string InputDir; sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; + PlaybackTrackMode Mode = PlaybackTrackMode::FrameIndex; uint32_t FrameIndex = 0; + std::string InTimecode; + uint32_t InFrameNumber = 0; std::string LastError; std::vector Frames; + std::vector Timecodes; // empty or same size as Frames + std::unordered_map TimecodeToIndex; + std::unordered_map FrameNumberToIndex; uint32_t CurrentFrame = 0; + PlaybackTrackCOLMAPContext(nosFbNodePtr node) : NodeContext(node) { if (node->pins()) @@ -66,6 +84,7 @@ struct PlaybackTrackCOLMAPContext : NodeContext } } } + ApplyModeOrphanStates(); UpdateStatus(); } @@ -86,8 +105,30 @@ struct PlaybackTrackCOLMAPContext : NodeContext if (!InputDir.empty()) LoadFromDirectory(); } + else if (pinName == NSN_Playback_Mode) + { + Mode = *(PlaybackTrackMode*)val.Data; + ApplyModeOrphanStates(); + } else if (pinName == NSN_Playback_InFrameIndex) FrameIndex = *(uint32_t*)val.Data; + else if (pinName == NSN_Playback_InTimecode) + InTimecode = InterpretPinValue(val.Data); + else if (pinName == NSN_Playback_InFrameNumber) + InFrameNumber = *(uint32_t*)val.Data; + } + + void ApplyModeOrphanStates() + { + auto state = [](bool active) { + return active ? fb::PinOrphanStateType::ACTIVE : fb::PinOrphanStateType::PASSIVE; + }; + const bool useIdx = Mode == PlaybackTrackMode::FrameIndex; + const bool useTC = Mode == PlaybackTrackMode::Timecode; + const bool useFN = Mode == PlaybackTrackMode::FrameNumber; + SetPinOrphanState(NSN_Playback_InFrameIndex, state(useIdx)); + SetPinOrphanState(NSN_Playback_InTimecode, state(useTC)); + SetPinOrphanState(NSN_Playback_InFrameNumber, state(useFN)); } void UpdateFrameCountPin() @@ -158,6 +199,11 @@ struct PlaybackTrackCOLMAPContext : NodeContext Frames.clear(); Frames.reserve(images.size()); + Timecodes.clear(); + + auto timecodesPath = dir / "timecodes.txt"; + if (std::filesystem::exists(timecodesPath)) + ParseTimecodesTxt(timecodesPath, images.size()); for (auto& img : images) { @@ -278,6 +324,40 @@ struct PlaybackTrackCOLMAPContext : NodeContext return true; } + void ParseTimecodesTxt(const std::filesystem::path& path, size_t expectedCount) + { + std::ifstream file(path); + if (!file.is_open()) + return; + std::unordered_map byId; + std::string line; + while (std::getline(file, line)) + { + if (line.empty() || line[0] == '#') + continue; + std::istringstream ss(line); + uint32_t id = 0; + TimecodeEntry e; + ss >> id >> e.Timecode >> e.FrameNumber; + if (e.Timecode == "-") + e.Timecode.clear(); + byId[id] = std::move(e); + } + Timecodes.assign(expectedCount, TimecodeEntry{}); + TimecodeToIndex.clear(); + FrameNumberToIndex.clear(); + for (size_t i = 0; i < expectedCount; ++i) + { + auto it = byId.find(uint32_t(i + 1)); + if (it == byId.end()) + continue; + Timecodes[i] = it->second; + if (!Timecodes[i].Timecode.empty()) + TimecodeToIndex.emplace(Timecodes[i].Timecode, uint32_t(i)); + FrameNumberToIndex.emplace(Timecodes[i].FrameNumber, uint32_t(i)); + } + } + // --- Euler extraction (inverse of EulerToRotationMatrix in RecordTrackCOLMAP) --- static glm::vec3 RotationMatrixToEuler(const glm::mat3& R_c2w, sys::track::CoordinateSystem order) @@ -299,6 +379,33 @@ struct PlaybackTrackCOLMAPContext : NodeContext // --- Execution --- + bool ResolveFrameIndex(uint32_t& outIdx) + { + switch (Mode) + { + case PlaybackTrackMode::Timecode: + { + auto it = TimecodeToIndex.find(InTimecode); + if (it == TimecodeToIndex.end()) + return false; + outIdx = it->second; + return true; + } + case PlaybackTrackMode::FrameNumber: + { + auto it = FrameNumberToIndex.find(InFrameNumber); + if (it == FrameNumberToIndex.end()) + return false; + outIdx = it->second; + return true; + } + case PlaybackTrackMode::FrameIndex: + default: + outIdx = FrameIndex < (uint32_t)Frames.size() ? FrameIndex : (uint32_t)Frames.size() - 1; + return true; + } + } + nosResult ExecuteNode(nosNodeExecuteParams* params) override { if (Frames.empty()) @@ -309,7 +416,9 @@ struct PlaybackTrackCOLMAPContext : NodeContext return NOS_RESULT_SUCCESS; } - uint32_t frameIdx = FrameIndex < (uint32_t)Frames.size() ? FrameIndex : (uint32_t)Frames.size() - 1; + uint32_t frameIdx = 0; + if (!ResolveFrameIndex(frameIdx)) + frameIdx = CurrentFrame < (uint32_t)Frames.size() ? CurrentFrame : 0; CurrentFrame = frameIdx; auto buf = nos::Buffer::From(Frames[frameIdx]); diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 0284b2f6..8731edf3 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -39,6 +39,8 @@ struct RecordedFrame float PixelAspectRatio; float K1; float K2; + std::string Timecode; + uint32_t FrameNumber; }; struct RecordTrackCOLMAPContext : NodeContext @@ -213,7 +215,7 @@ struct RecordTrackCOLMAPContext : NodeContext nosResult ExecuteNode(nosNodeExecuteParams* params) override { - auto pins = GetPinValues(params); + nos::NodeExecuteParams execParams(params); // Pass through Track input to output nosBuffer trackBuf{}; @@ -235,6 +237,9 @@ struct RecordTrackCOLMAPContext : NodeContext return NOS_RESULT_SUCCESS; RecordedFrame frame{}; + if (const char* tc = execParams.GetPinData(NOS_NAME_STATIC("Timecode"))) + frame.Timecode = tc; + frame.FrameNumber = *execParams.GetPinData(NOS_NAME_STATIC("FrameNumber")); if (auto* loc = trackData->location()) frame.Location = {loc->x(), loc->y(), loc->z()}; if (auto* rot = trackData->rotation()) @@ -285,9 +290,39 @@ struct RecordTrackCOLMAPContext : NodeContext WriteCamerasTxt(outDir); WriteImagesTxt(outDir); + WriteTimecodesTxt(outDir); nosEngine.LogI("RecordTrackCOLMAP: Saved %zu frames to %s", Frames.size(), OutputDir.c_str()); } + void WriteTimecodesTxt(const std::filesystem::path& outDir) + { + // Skip the sidecar entirely if no frame carried a timecode — keeps the + // output minimal when the upstream graph isn't producing TC. + bool any = false; + for (auto& f : Frames) + if (!f.Timecode.empty() || f.FrameNumber != 0) { any = true; break; } + if (!any) + return; + + auto path = outDir / "timecodes.txt"; + std::ofstream file(path); + if (!file.is_open()) + { + nosEngine.LogE("RecordTrackCOLMAP: Cannot open %s", nos::PathToUtf8(path).c_str()); + return; + } + file << "# Timecode sidecar paired with images.txt by IMAGE_ID.\n"; + file << "# IMAGE_ID, TIMECODE, FRAME_NUMBER\n"; + file << "# Number of entries: " << Frames.size() << "\n"; + for (size_t i = 0; i < Frames.size(); ++i) + { + const auto& f = Frames[i]; + file << (i + 1) << " " + << (f.Timecode.empty() ? "-" : f.Timecode) << " " + << f.FrameNumber << "\n"; + } + } + float ComputeFocalLengthPixels(const RecordedFrame& frame) const { if (frame.FOV <= 0.0f) diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index 97473fef..dc975d34 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -20,6 +20,9 @@ "Config/RecordTrackCOLMAP.nosdef", "Config/PlaybackTrackCOLMAP.nosdef" ], + "custom_types": [ + "Config/PlaybackMode.fbs" + ], "defaults": [ "Config/Defaults.json" ], From eb36d508bccbd0563e914ba1d9706b28032ac164 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 29 Apr 2026 18:06:11 +0300 Subject: [PATCH 13/30] Dynamically add multiple pins to Sink node --- Plugins/nosUtilities/Source/Sink.cpp | 133 +++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/Plugins/nosUtilities/Source/Sink.cpp b/Plugins/nosUtilities/Source/Sink.cpp index 043982f3..28294d7e 100644 --- a/Plugins/nosUtilities/Source/Sink.cpp +++ b/Plugins/nosUtilities/Source/Sink.cpp @@ -5,6 +5,7 @@ // stl #include +#include #include #include "Sink_generated.h" @@ -18,6 +19,33 @@ constexpr uint64_t VULKAN_TIMEOUT_BEFORE_LEAK = struct SinkNode : NodeContext { + enum MenuCommandType : uint8_t + { + ADD_INPUT = 0, + REMOVE_INPUT = 1, + }; + + struct MenuCommand + { + MenuCommandType Type; + uint8_t InputIndex; + MenuCommand(uint32_t cmd) { + Type = static_cast(cmd & 0xFF); + InputIndex = static_cast((cmd >> 8) & 0xFF); + } + MenuCommand(MenuCommandType type, uint8_t inputIndex) : Type(type), InputIndex(inputIndex) {} + operator uint32_t() const { return (InputIndex << 8) | Type; } + }; + + static const std::unordered_set& StaticPinNames() + { + static const std::unordered_set names = { + "InExe", "Sink Input", "Sink FPS", "HasGPUWork", "GPUFrameBuffering", + "AcceptsRepeat", "SinkMode", "LatencyBudget" + }; + return names; + } + std::mutex Mutex; std::atomic ShouldStop = false; std::atomic Fps = 1000.0f / 60.0f; @@ -31,9 +59,27 @@ struct SinkNode : NodeContext std::optional> GPUFrameSyncEvents = std::nullopt; size_t GPUFrameBuffering = 1; uint64_t CurrentGPUEventIndex = 0; + std::vector DynamicInputs; SinkNode(nosFbNodePtr inNode) : NodeContext(inNode) { + std::list pinsToUnorphan; + for (auto i = 0; i < inNode->pins()->size(); i++) + { + auto pin = inNode->pins()->Get(i); + if (pin->show_as() != fb::ShowAs::INPUT_PIN) + continue; + if (StaticPinNames().contains(pin->name()->string_view())) + continue; + DynamicInputs.push_back(*pin->id()); + if (auto orphanState = pin->orphan_state()) + { + if (orphanState->type() == fb::PinOrphanStateType::ORPHAN) + pinsToUnorphan.push_back(*pin->id()); + } + } + for (auto const& pinId : pinsToUnorphan) + SetPinOrphanState(pinId, fb::PinOrphanStateType::ACTIVE); AddPinValueWatcher(NOS_NAME("HasGPUWork"), [this](nosBuffer const& newVal, std::optional oldValue) { bool hasGpuWork = *static_cast(newVal.Data); @@ -255,6 +301,93 @@ struct SinkNode : NodeContext } } + void OnNodeMenuRequested(nosContextMenuRequestPtr request) override + { + uint32_t cmd = MenuCommand(ADD_INPUT, 0); + flatbuffers::FlatBufferBuilder fbb; + std::vector items = { + nos::CreateContextMenuItemDirect(fbb, "Add Sink", cmd, nullptr) + }; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnPinMenuRequested(nos::Name pinName, nosContextMenuRequestPtr request) override + { + if (StaticPinNames().contains(pinName.AsString())) + return; + auto pinId = GetPinId(pinName); + if (!pinId) + return; + auto it = std::find(DynamicInputs.begin(), DynamicInputs.end(), *pinId); + if (it == DynamicInputs.end()) + return; + auto index = std::distance(DynamicInputs.begin(), it); + uint32_t cmd = MenuCommand(REMOVE_INPUT, static_cast(index)); + flatbuffers::FlatBufferBuilder fbb; + std::vector items = { + nos::CreateContextMenuItemDirect(fbb, "Remove Input", cmd, nullptr) + }; + HandleEvent(CreateAppEvent(fbb, app::CreateAppContextMenuUpdateDirect( + fbb, request->item_id(), request->pos(), request->instigator(), &items))); + } + + void OnMenuCommand(uuid const& itemID, uint32_t cmd) override + { + auto command = MenuCommand(cmd); + switch (command.Type) + { + case ADD_INPUT: + { + std::string pinName; + for (size_t i = 2;; i++) + { + auto candidate = "Sink Input " + std::to_string(i); + if (!GetPinId(nos::Name(candidate))) + { + pinName = std::move(candidate); + break; + } + } + flatbuffers::FlatBufferBuilder fbb; + uuid pinId = nosEngine.GenerateID(); + std::vector pins = { + fb::CreatePinDirect(fbb, &pinId, pinName.c_str(), "nos.Generic", + fb::ShowAs::INPUT_PIN, fb::CanShowAs::INPUT_PIN_ONLY) + }; + HandleEvent(CreateAppEvent(fbb, CreatePartialNodeUpdateDirect(fbb, &NodeId, ClearFlags::NONE, 0, &pins))); + break; + } + case REMOVE_INPUT: + { + if (command.InputIndex >= DynamicInputs.size()) + return; + auto pinId = DynamicInputs[command.InputIndex]; + flatbuffers::FlatBufferBuilder fbb; + std::vector pinsToRemove = { *&pinId }; + HandleEvent(CreateAppEvent(fbb, CreatePartialNodeUpdateDirect(fbb, &NodeId, ClearFlags::NONE, &pinsToRemove))); + break; + } + } + } + + void OnNodeUpdated(nosNodeUpdate const* update) override + { + if (update->Type == NOS_NODE_UPDATE_PIN_DELETED) + { + std::erase_if(DynamicInputs, [&](auto id) { return id == update->PinDeleted; }); + } + else if (update->Type == NOS_NODE_UPDATE_PIN_CREATED) + { + auto* pin = update->PinCreated; + if (pin->show_as() != fb::ShowAs::INPUT_PIN) + return; + if (StaticPinNames().contains(pin->name()->string_view())) + return; + DynamicInputs.push_back(*pin->id()); + } + } + void GetScheduleInfo(nosScheduleInfo* info) override { info->Type = NOS_SCHEDULE_TYPE_ON_DEMAND; From ec08c0107547fb00caab233243e91131ab623520 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 29 Apr 2026 18:06:36 +0300 Subject: [PATCH 14/30] Do not pile up schedule requests in multi-ring buffer --- .../nosUtilities/Source/MultiBoundedQueue.cpp | 33 +++++++++++++++++-- .../nosUtilities/Source/MultiRingBuffer.cpp | 28 +++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp index 3f6eab32..2c69bf50 100644 --- a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp +++ b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp @@ -2,6 +2,8 @@ #pragma once +#include + #include // External @@ -62,6 +64,11 @@ struct MultiBoundedQueueNodeContext : NodeContext std::map> Channels; std::unordered_map PinIdToLetter; MultiRing Ring; + // Channels popped since the last SendScheduleRequest. One producer run + // pushes one slot per live channel, so we must only schedule again once + // every live channel has been popped — otherwise schedule requests pile + // up by a factor of N (channels) per consumer tick. + std::set PoppedSinceLastSchedule; std::optional RequestedRingSize = std::nullopt; @@ -141,7 +148,10 @@ struct MultiBoundedQueueNodeContext : NodeContext } } if (any) + { Ring.Stop(); + PoppedSinceLastSchedule.clear(); + } }); } @@ -186,6 +196,7 @@ struct MultiBoundedQueueNodeContext : NodeContext nosEngine.SendPathCommand(ch->InputId, ringSizeChange); } Ring.Stop(); + PoppedSinceLastSchedule.clear(); SendPathRestart(); RequestedRingSize = size; } @@ -208,6 +219,7 @@ struct MultiBoundedQueueNodeContext : NodeContext { nosEngine.SendPathRestart(ch->InputId); Ring.Stop(); + PoppedSinceLastSchedule.clear(); ch->NeedsRecreation = true; } } @@ -228,6 +240,7 @@ struct MultiBoundedQueueNodeContext : NodeContext if (ch.RingChannel) { Ring.Stop(); + PoppedSinceLastSchedule.clear(); Ring.RemoveChannel(*letter); ch.RingChannel = nullptr; } @@ -388,7 +401,17 @@ struct MultiBoundedQueueNodeContext : NodeContext cpy->FrameNumber = slot->FrameNumber; Ring.EndPop(*ch->RingChannel, slot); - SendScheduleRequest(1); + + PoppedSinceLastSchedule.insert(ch->Letter); + size_t liveCount = 0; + for (auto& [_, c] : Channels) + if (c->IsOutLive) + ++liveCount; + if (PoppedSinceLastSchedule.size() >= liveCount) + { + SendScheduleRequest(1); + PoppedSinceLastSchedule.clear(); + } return NOS_RESULT_SUCCESS; } @@ -427,13 +450,19 @@ struct MultiBoundedQueueNodeContext : NodeContext } } - void OnPathStop() override { Ring.Stop(); } + void OnPathStop() override + { + Ring.Stop(); + PoppedSinceLastSchedule.clear(); + } void OnPathStart() override { if (Channels.empty()) return; + PoppedSinceLastSchedule.clear(); + Ring.ResetAll(false); if (RequestedRingSize) diff --git a/Plugins/nosUtilities/Source/MultiRingBuffer.cpp b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp index 54304f41..2c446299 100644 --- a/Plugins/nosUtilities/Source/MultiRingBuffer.cpp +++ b/Plugins/nosUtilities/Source/MultiRingBuffer.cpp @@ -2,6 +2,8 @@ #pragma once +#include + #include // External @@ -67,6 +69,11 @@ struct MultiRingBufferNodeContext : NodeContext std::map> Channels; std::unordered_map PinIdToLetter; MultiRing Ring; + // Channels popped since the last SendScheduleRequest. One producer run + // pushes one slot per live channel, so we must only schedule again once + // every live channel has been popped — otherwise schedule requests pile + // up by a factor of N (channels) per consumer tick. + std::set PoppedSinceLastSchedule; OnRestartType OnRestart = OnRestartType::WAIT_UNTIL_FULL; std::optional RequestedRingSize = std::nullopt; @@ -151,7 +158,10 @@ struct MultiRingBufferNodeContext : NodeContext } } if (any) + { Ring.Stop(); + PoppedSinceLastSchedule.clear(); + } }); AddPinValueWatcher(NOS_NAME_STATIC("RepeatWhenFilling"), [this](nos::Buffer const& newVal, std::optional oldVal) { @@ -228,6 +238,7 @@ struct MultiRingBufferNodeContext : NodeContext nosEngine.SendPathCommand(ch->InputId, ringSizeChange); } Ring.Stop(); + PoppedSinceLastSchedule.clear(); SendPathRestart(); RequestedRingSize = size; } @@ -250,6 +261,7 @@ struct MultiRingBufferNodeContext : NodeContext { nosEngine.SendPathRestart(ch->InputId); Ring.Stop(); + PoppedSinceLastSchedule.clear(); ch->NeedsRecreation = true; } } @@ -271,6 +283,7 @@ struct MultiRingBufferNodeContext : NodeContext if (ch.RingChannel) { Ring.Stop(); + PoppedSinceLastSchedule.clear(); Ring.RemoveChannel(*letter); ch.RingChannel = nullptr; } @@ -493,7 +506,17 @@ struct MultiRingBufferNodeContext : NodeContext cpy->FrameNumber = slot->FrameNumber; ch->LastPopped = slot; - SendScheduleRequest(1); + + PoppedSinceLastSchedule.insert(ch->Letter); + size_t liveCount = 0; + for (auto& [_, c] : Channels) + if (c->IsOutLive) + ++liveCount; + if (PoppedSinceLastSchedule.size() >= liveCount) + { + SendScheduleRequest(1); + PoppedSinceLastSchedule.clear(); + } return NOS_RESULT_SUCCESS; } @@ -550,6 +573,7 @@ struct MultiRingBufferNodeContext : NodeContext } } Ring.Stop(); + PoppedSinceLastSchedule.clear(); } void OnPathStart() override @@ -557,6 +581,8 @@ struct MultiRingBufferNodeContext : NodeContext if (Channels.empty()) return; + PoppedSinceLastSchedule.clear(); + if (OnRestart == OnRestartType::RESET || RepeatWhenFilling) Ring.ResetAll(false); else From 246f304b541c3dd7731811ad8a3c61a568ae6551 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 29 Apr 2026 18:47:51 +0300 Subject: [PATCH 15/30] RecordTrackCOLMAP: Disable function orphan toggling to avoid path recompile --- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 8731edf3..9a20292c 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -87,16 +87,12 @@ struct RecordTrackCOLMAPContext : NodeContext 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); - } + // Disabled: toggling NodeOrphanState on Record/Stop function nodes invalidates the + // engine's compiled execution path, causing a recompile hitch on the first recorded + // frame. NodeOrphanStateType has no PASSIVE variant (only pins do), so we can't + // express "visual-only, no topology change" today. Re-enable once the engine adds + // PASSIVE to NodeOrphanStateType (or otherwise skips path invalidation for + // orphan-state changes on function nodes). } void SyncRecordPin(bool value) From f3e8f6ae116e631704f64044230d04fd1305aabc Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 5 May 2026 12:29:07 +0300 Subject: [PATCH 16/30] Auto-export COLMAP files when RecordTrackCOLMAP stops WriteFiles() is now invoked from StopRecording() so cameras.txt and images.txt are written the moment recording ends. The Save function remains available for re-exporting the buffered frames on demand. --- Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef | 2 +- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 45342e37..3efaf7ae 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -12,7 +12,7 @@ "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.", + "description": "Records camera tracking data each frame while recording is enabled, then exports cameras.txt and images.txt in COLMAP format the moment recording stops. The Save function is still available for re-exporting the buffered frames on demand. 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", diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 9a20292c..8e775553 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -129,8 +129,10 @@ struct RecordTrackCOLMAPContext : NodeContext SyncRecordPin(false); UpdateRecordingFramePin(); UpdateFunctionOrphanStates(); - UpdateStatus(); nosEngine.LogI("RecordTrackCOLMAP: Recording stopped (%zu frames in buffer)", Frames.size()); + if (!Frames.empty()) + WriteFiles(); + UpdateStatus(); } void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer val) override From 28fe3bcc29c61c84b421b3fe7396089e6dd8984d Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 5 May 2026 16:38:40 +0300 Subject: [PATCH 17/30] MultiBoundedQueue: Propagate slot descriptor to output pin in CopyFrom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CopyFrom went straight from BeginPop to Copy, so the GPU copy used the stale default-sized output pin descriptor as its destination. Mirror the single BoundedQueue path (CommonCopyFrom) and MultiRingBuffer by calling BeginCopyFrom and writing the slot resource's descriptor onto the output pin first. The ring resources themselves are still (re)allocated from the input descriptor in OnPathStart — this only fixes the output-side descriptor propagation. --- Plugins/nosUtilities/Source/MultiBoundedQueue.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp index 2c69bf50..9f433574 100644 --- a/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp +++ b/Plugins/nosUtilities/Source/MultiBoundedQueue.cpp @@ -395,6 +395,13 @@ struct MultiBoundedQueueNodeContext : NodeContext if (!slot) return Ring.Exit ? NOS_RESULT_FAILED : NOS_RESULT_PENDING; + // Propagate the slot resource's descriptor onto the output pin before + // Copy reads cpy->PinData as the destination — otherwise the GPU copy + // targets the stale (default-sized) output descriptor. + nos::Buffer outPinVal; + if (ch->RingChannel->ResInterface->BeginCopyFrom(slot, *cpy->PinData, outPinVal)) + nosEngine.SetPinValueByName(NodeId, ch->OutputName, outPinVal); + ch->RingChannel->ResInterface->Copy(slot, cpy, NodeId); cpy->CopyFromOptions.ShouldSetSourceFrameNumber = true; From f76e468e40faaeb715cca498ebe0f045a9d00b2e Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 6 May 2026 00:29:01 +0300 Subject: [PATCH 18/30] Pin-driven RecordTrackCOLMAP and extended track field capture Recording is now driven by the Record pin alone; the dedicated Record/Stop/Save/Clear/OpenFolder functions are removed. Stop is debounced via a new MinOffFrames property so brief glitches in the upstream signal (e.g. SDI bit flips on a camera-derived recording flag) don't end a take prematurely. Buffer is auto-cleared on stop. RecordedFrame is widened to capture Zoom, Focus, RenderRatio, NodalOffset, CenterShift, and DistortionScale from the incoming Track. PlaybackTrackCOLMAP gains a paired ExtrasEntry sidecar so the same fields can be replayed. --- .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 46 +--- .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 113 ++++++++- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 217 +++++++----------- 3 files changed, 203 insertions(+), 173 deletions(-) diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 3efaf7ae..34cb779f 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -12,7 +12,7 @@ "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 the moment recording stops. The Save function is still available for re-exporting the buffered frames on demand. 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.", + "description": "Records camera tracking data each frame while the Record pin is true, then exports cameras.txt and images.txt in COLMAP format the moment recording stops, and clears the buffer. The Record pin is the single driver — wire it to an upstream Recording flag (e.g. from SonyVeniceANCParser) for automatic record-following capture. The MinOffFrames pin debounces brief drops in the Record signal so SDI bit flips don't end a take prematurely. 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", @@ -83,7 +83,17 @@ "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." + "description": "Drives recording: rising edge clears the buffer and starts capture; falling edge stops capture (subject to MinOffFrames debouncing) and exports the COLMAP files. Will fail to start if the output directory is not empty." + }, + { + "name": "MinOffFrames", + "display_name": "Min Off Frames", + "type_name": "uint", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1, + "min": "1", + "description": "Debounce: minimum number of consecutive frames with Record=false before recording actually stops. Default 1 = stop immediately. Use higher values (e.g. 5-15) to ride out short glitches in the upstream Record signal — useful when Record is wired to a camera-derived flag that can momentarily flip due to SDI bit errors." }, { "name": "RecordingFrame", @@ -103,38 +113,6 @@ "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 index 79f629f1..50d405bb 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -55,6 +55,22 @@ struct TimecodeEntry uint32_t FrameNumber = 0; }; +struct ExtrasEntry +{ + bool Present = false; + float Zoom = 0; + float Focus = 0; + float FocusDistance = 0; + float RenderRatio = 0; + float NodalOffset = 0; + float DistortionScale = 0; + float SensorWmm = 0; + float SensorHmm = 0; + float RotX = 0; + float RotY = 0; + float RotZ = 0; +}; + struct PlaybackTrackCOLMAPContext : NodeContext { std::string InputDir; @@ -205,29 +221,81 @@ struct PlaybackTrackCOLMAPContext : NodeContext if (std::filesystem::exists(timecodesPath)) ParseTimecodesTxt(timecodesPath, images.size()); - for (auto& img : images) + std::vector extras; + auto extrasPath = dir / "extras.txt"; + if (std::filesystem::exists(extrasPath)) + ParseExtrasTxt(extrasPath, images.size(), extras); + + for (size_t i = 0; i < images.size(); ++i) { + auto& img = images[i]; sys::track::TTrack trackData{}; auto camIt = cameras.find(img.CameraId); + const ExtrasEntry* ex = (i < extras.size() && extras[i].Present) ? &extras[i] : nullptr; - // Convert COLMAP world-to-camera back to camera-to-world + // Position: invert COLMAP world-to-camera. Stable round-trip. 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); + + // Rotation: prefer the original Euler from extras (avoids quaternion- + // to-Euler ambiguity near gimbal lock); fall back to extracting from + // the COLMAP rotation matrix when no extras sidecar exists. + if (ex) + { + glm::vec3 euler(ex->RotX, ex->RotY, ex->RotZ); + trackData.rotation = reinterpret_cast(euler); + } + else + { + glm::vec3 euler = RotationMatrixToEuler(R_c2w, CoordSys); + 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); + + // sensor_size: COLMAP only stores pixel dims, but Track expects mm. + // Use the extras value when present; otherwise fall back to pixels + // (matches pre-extras behaviour). + glm::vec2 sensorMm(0); + if (ex && ex->SensorWmm > 0 && ex->SensorHmm > 0) + { + sensorMm = {ex->SensorWmm, ex->SensorHmm}; + trackData.sensor_size = nos::fb::vec2(sensorMm.x, sensorMm.y); + } + else + { + trackData.sensor_size = nos::fb::vec2(cam.Width, cam.Height); + } + + // center_shift: invert the (cx, cy) encoding written by record. + // Needs sensor_size in mm to be meaningful, so only reconstructed + // when extras provided it. + if (sensorMm.x > 0 && cam.Width > 0 && sensorMm.y > 0 && cam.Height > 0) + { + glm::vec2 shift{ + (cam.Cx - cam.Width * 0.5f) * sensorMm.x / cam.Width, + (cam.Cy - cam.Height * 0.5f) * sensorMm.y / cam.Height}; + trackData.lens_distortion.mutable_center_shift() = reinterpret_cast(shift); + } + } + + if (ex) + { + trackData.zoom = ex->Zoom; + trackData.focus = ex->Focus; + trackData.focus_distance = ex->FocusDistance; + trackData.render_ratio = ex->RenderRatio; + trackData.nodal_offset = ex->NodalOffset; + trackData.lens_distortion.mutate_distortion_scale(ex->DistortionScale); } Frames.push_back(std::move(trackData)); @@ -324,6 +392,39 @@ struct PlaybackTrackCOLMAPContext : NodeContext return true; } + void ParseExtrasTxt(const std::filesystem::path& path, size_t expectedCount, std::vector& outExtras) + { + std::ifstream file(path); + if (!file.is_open()) + return; + std::unordered_map byId; + std::string line; + while (std::getline(file, line)) + { + if (line.empty() || line[0] == '#') + continue; + std::istringstream ss(line); + uint32_t id = 0; + ExtrasEntry e; + ss >> id >> e.Zoom >> e.Focus >> e.FocusDistance >> e.RenderRatio + >> e.NodalOffset >> e.DistortionScale + >> e.SensorWmm >> e.SensorHmm + >> e.RotX >> e.RotY >> e.RotZ; + if (!ss.fail()) + { + e.Present = true; + byId[id] = e; + } + } + outExtras.assign(expectedCount, ExtrasEntry{}); + for (size_t i = 0; i < expectedCount; ++i) + { + auto it = byId.find(uint32_t(i + 1)); + if (it != byId.end()) + outExtras[i] = it->second; + } + } + void ParseTimecodesTxt(const std::filesystem::path& path, size_t expectedCount) { std::ifstream file(path); diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 8e775553..2ae9ef2d 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -20,25 +20,26 @@ NOS_REGISTER_NAME(OutputDirectory); NOS_REGISTER_NAME(ImageResolution); NOS_REGISTER_NAME(CoordinateSystem); NOS_REGISTER_NAME(Record); +NOS_REGISTER_NAME(MinOffFrames); 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; + float Zoom; + float Focus; + float RenderRatio; glm::vec2 SensorSize; - float FocusDistance; float PixelAspectRatio; + float NodalOffset; + float FocusDistance; float K1; float K2; + glm::vec2 CenterShift; + float DistortionScale; std::string Timecode; uint32_t FrameNumber; }; @@ -49,19 +50,13 @@ struct RecordTrackCOLMAPContext : NodeContext nosVec2u ImageResolution = {1920, 1080}; sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; bool Recording = false; - bool SyncingRecordPin = false; + uint32_t ConsecutiveOffFrames = 0; + bool LastRequestRecord = 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()) @@ -74,34 +69,9 @@ struct RecordTrackCOLMAPContext : NodeContext } } } - 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() - { - // Disabled: toggling NodeOrphanState on Record/Stop function nodes invalidates the - // engine's compiled execution path, causing a recompile hitch on the first recorded - // frame. NodeOrphanStateType has no PASSIVE variant (only pins do), so we can't - // express "visual-only, no topology change" today. Re-enable once the engine adds - // PASSIVE to NodeOrphanStateType (or otherwise skips path invalidation for - // orphan-state changes on function nodes). - } - - void SyncRecordPin(bool value) - { - SyncingRecordPin = true; - SetPinValue(NSN_Record, nosBuffer{.Data = &value, .Size = sizeof(value)}); - SyncingRecordPin = false; - } - bool StartRecording() { std::string error; @@ -114,10 +84,9 @@ struct RecordTrackCOLMAPContext : NodeContext LastError.clear(); Frames.clear(); Recording = true; - SyncRecordPin(true); + ConsecutiveOffFrames = 0; UpdateFrameCountPin(); UpdateRecordingFramePin(); - UpdateFunctionOrphanStates(); UpdateStatus(); nosEngine.LogI("RecordTrackCOLMAP: Recording started"); return true; @@ -126,12 +95,12 @@ struct RecordTrackCOLMAPContext : NodeContext void StopRecording() { Recording = false; - SyncRecordPin(false); - UpdateRecordingFramePin(); - UpdateFunctionOrphanStates(); nosEngine.LogI("RecordTrackCOLMAP: Recording stopped (%zu frames in buffer)", Frames.size()); if (!Frames.empty()) WriteFiles(); + Frames.clear(); + UpdateFrameCountPin(); + UpdateRecordingFramePin(); UpdateStatus(); } @@ -147,16 +116,6 @@ struct RecordTrackCOLMAPContext : NodeContext 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) @@ -205,8 +164,6 @@ struct RecordTrackCOLMAPContext : NodeContext 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); } @@ -227,6 +184,27 @@ struct RecordTrackCOLMAPContext : NodeContext } SetPinValue(NOS_NAME("OutTrack"), trackBuf); + // Drive recording state from the Record pin, with off-state debouncing to + // ride out brief glitches in the upstream signal (e.g. SDI bit flips on a + // camera-derived recording flag). Start happens immediately on a rising + // edge; stop only after MinOffFrames consecutive false frames. + const bool requestRecord = *execParams.GetPinData(NSN_Record); + const uint32_t minOffFrames = *execParams.GetPinData(NSN_MinOffFrames); + + const bool risingEdge = requestRecord && !LastRequestRecord; + LastRequestRecord = requestRecord; + + if (risingEdge && !Recording) + StartRecording(); + + if (Recording) + { + if (requestRecord) + ConsecutiveOffFrames = 0; + else if (++ConsecutiveOffFrames >= std::max(1u, minOffFrames)) + StopRecording(); + } + if (!Recording) return NOS_RESULT_SUCCESS; @@ -243,14 +221,20 @@ struct RecordTrackCOLMAPContext : NodeContext if (auto* rot = trackData->rotation()) frame.Rotation = {rot->x(), rot->y(), rot->z()}; frame.FOV = trackData->fov(); + frame.Zoom = trackData->zoom(); + frame.Focus = trackData->focus(); + frame.RenderRatio = trackData->render_ratio(); if (auto* ss = trackData->sensor_size()) frame.SensorSize = {ss->x(), ss->y()}; - frame.FocusDistance = trackData->focus_distance(); frame.PixelAspectRatio = trackData->pixel_aspect_ratio(); + frame.NodalOffset = trackData->nodal_offset(); + frame.FocusDistance = trackData->focus_distance(); if (auto* ld = trackData->lens_distortion()) { frame.K1 = ld->k1k2().x(); frame.K2 = ld->k1k2().y(); + frame.CenterShift = {ld->center_shift().x(), ld->center_shift().y()}; + frame.DistortionScale = ld->distortion_scale(); } Frames.push_back(frame); @@ -289,9 +273,44 @@ struct RecordTrackCOLMAPContext : NodeContext WriteCamerasTxt(outDir); WriteImagesTxt(outDir); WriteTimecodesTxt(outDir); + WriteExtrasTxt(outDir); nosEngine.LogI("RecordTrackCOLMAP: Saved %zu frames to %s", Frames.size(), OutputDir.c_str()); } + void WriteExtrasTxt(const std::filesystem::path& outDir) + { + // Sidecar for Track fields that don't fit COLMAP's standard cameras.txt / + // images.txt format. Keyed by IMAGE_ID so it pairs 1:1 with images.txt. + auto path = outDir / "extras.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 << "# Nodos Track sidecar paired with images.txt by IMAGE_ID.\n"; + file << "# Carries fields that don't fit COLMAP's cameras.txt/images.txt:\n"; + file << "# - sensor_size in mm (cameras.txt only stores pixel WIDTH/HEIGHT)\n"; + file << "# - original Euler rotation in degrees (avoids quaternion round-trip drift)\n"; + file << "# - nodos-only fields with no COLMAP equivalent\n"; + file << "# IMAGE_ID, ZOOM, FOCUS, FOCUS_DISTANCE, RENDER_RATIO, NODAL_OFFSET, DISTORTION_SCALE, SENSOR_W_MM, SENSOR_H_MM, ROT_X, ROT_Y, ROT_Z\n"; + file << "# Number of entries: " << Frames.size() << "\n"; + for (size_t i = 0; i < Frames.size(); ++i) + { + const auto& f = Frames[i]; + file << (i + 1) << " " + << f.Zoom << " " + << f.Focus << " " + << f.FocusDistance << " " + << f.RenderRatio << " " + << f.NodalOffset << " " + << f.DistortionScale << " " + << f.SensorSize.x << " " << f.SensorSize.y << " " + << f.Rotation.x << " " << f.Rotation.y << " " << f.Rotation.z << "\n"; + } + } + void WriteTimecodesTxt(const std::filesystem::path& outDir) { // Skip the sidecar entirely if no frame carried a timecode — keeps the @@ -351,8 +370,15 @@ struct RecordTrackCOLMAPContext : NodeContext if (Frames[i].PixelAspectRatio > 0.0f) fy = fx / Frames[i].PixelAspectRatio; + // center_shift is in the same units as sensor_size (mm); convert to + // pixel offset on the principal point. See TrackToView.cpp:30 for the + // canonical centerShift / sensorSize relationship. float cx = ImageResolution.x * 0.5f; float cy = ImageResolution.y * 0.5f; + if (Frames[i].SensorSize.x > 0.0f) + cx += Frames[i].CenterShift.x * ImageResolution.x / Frames[i].SensorSize.x; + if (Frames[i].SensorSize.y > 0.0f) + cy += Frames[i].CenterShift.y * ImageResolution.y / Frames[i].SensorSize.y; // OPENCV model: fx, fy, cx, cy, k1, k2, p1, p2 float k1 = Frames[i].K1; @@ -423,81 +449,6 @@ struct RecordTrackCOLMAPContext : NodeContext 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) From d615a659422dd82dd8a6c0b734005847d4a2d7fe Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 7 May 2026 10:27:02 +0300 Subject: [PATCH 19/30] ChannelViewer: Use nos.mediaio.ColorSpace instead of own enum Drop the duplicated ChannelViewerFormats enum and retype the Format pin to nos.mediaio.ColorSpace, matching how the rest of the codebase (GetLumaCoeffs, ColorSpaceMatrix, AJA/DeckLink IO, CasWithColorSpace) already names this concept. The two enums disagreed on index ordering (Rec_601=0 vs REC709=0), so the luma coeffs array is reordered to match the new mapping. A MigrateNode hook rewrites legacy pin type_name and remaps the saved enum literal Rec_601/Rec_709/Rec_2020 -> REC601/REC709/REC2020 so existing graphs keep their selected color space. Co-Authored-By: Claude Opus 4.7 (1M context) --- Plugins/nosUtilities/Config/ChannelViewer.fbs | 6 --- .../nosUtilities/Config/ChannelViewer.nosdef | 4 +- Plugins/nosUtilities/Source/ChannelViewer.cpp | 39 ++++++++++++++++++- Plugins/nosUtilities/Source/UtilitiesMain.cpp | 2 +- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/Plugins/nosUtilities/Config/ChannelViewer.fbs b/Plugins/nosUtilities/Config/ChannelViewer.fbs index b5de7eb1..7042da21 100644 --- a/Plugins/nosUtilities/Config/ChannelViewer.fbs +++ b/Plugins/nosUtilities/Config/ChannelViewer.fbs @@ -9,9 +9,3 @@ enum ChannelViewerChannels : uint { Cb = 5, Cr = 6 } - -enum ChannelViewerFormats : uint { - Rec_601 = 0, - Rec_709 = 1, - Rec_2020 = 2 -} diff --git a/Plugins/nosUtilities/Config/ChannelViewer.nosdef b/Plugins/nosUtilities/Config/ChannelViewer.nosdef index 4a002d10..2722cba5 100644 --- a/Plugins/nosUtilities/Config/ChannelViewer.nosdef +++ b/Plugins/nosUtilities/Config/ChannelViewer.nosdef @@ -31,10 +31,10 @@ }, { "name": "Format", - "type_name": "nos.utilities.ChannelViewerFormats", + "type_name": "nos.mediaio.ColorSpace", "show_as": "PROPERTY", "can_show_as": "PROPERTY_ONLY", - "data": "Rec_709", + "data": "REC709", "description": "Sets the input texture color space,\nRequired for correct YCbCr conversion" }, { diff --git a/Plugins/nosUtilities/Source/ChannelViewer.cpp b/Plugins/nosUtilities/Source/ChannelViewer.cpp index a0d88f67..4fcc2e96 100644 --- a/Plugins/nosUtilities/Source/ChannelViewer.cpp +++ b/Plugins/nosUtilities/Source/ChannelViewer.cpp @@ -13,6 +13,41 @@ NOS_REGISTER_NAME_SPACED(Nos_Utilities_ChannelViewer, "nos.utilities.ChannelView namespace nos::utilities { +static nosResult MigrateNode(nosFbNodePtr nodePtr, nosBuffer* outBuffer) +{ + fb::TNode tNode; + nodePtr->UnPackTo(&tNode); + bool migrated = false; + for (auto& pin : tNode.pins) + { + if (!pin || pin->name != "Format") + continue; + bool legacyType = pin->type_name == "nos.utilities.ChannelViewerFormats" || + pin->type_name == "nos.fb.ChannelViewerFormats"; + const char* newValue = nullptr; + if (!pin->data.empty()) + { + std::string_view oldValue(reinterpret_cast(pin->data.data()), pin->data.size() - 1); + if (oldValue == "Rec_601") newValue = "REC601"; + else if (oldValue == "Rec_709") newValue = "REC709"; + else if (oldValue == "Rec_2020") newValue = "REC2020"; + } + if (!legacyType && !newValue) + continue; + pin->type_name = "nos.mediaio.ColorSpace"; + if (newValue) + { + std::string s = newValue; + pin->data = std::vector(s.c_str(), s.c_str() + s.size() + 1); + } + migrated = true; + } + if (!migrated) + return NOS_RESULT_SUCCESS; + *outBuffer = EngineBuffer::CopyFrom(tNode).Release(); + return NOS_RESULT_SUCCESS; +} + static nosResult ExecuteNode(void* ctx, nosNodeExecuteParams* pins) { auto values = GetPinValues(pins); @@ -25,7 +60,8 @@ static nosResult ExecuteNode(void* ctx, nosNodeExecuteParams* pins) glm::vec4 val{}; val[channel & 3] = 1; - constexpr glm::vec3 coeffs[3] = {{.299f, .587f, .114f}, {.2126f, .7152f, .0722f}, {.2627f, .678f, .0593f}}; + // Indexed by nos.mediaio.ColorSpace: REC709=0, REC601=1, REC2020=2 + constexpr glm::vec3 coeffs[3] = {{.2126f, .7152f, .0722f}, {.299f, .587f, .114f}, {.2627f, .678f, .0593f}}; glm::vec4 multipliers = glm::vec4(coeffs[format], channel > 3); std::vector bindings = { @@ -51,6 +87,7 @@ nosResult RegisterChannelViewer(nosNodeFunctions* out) { out->ClassName = NSN_Nos_Utilities_ChannelViewer; out->ExecuteNode = ExecuteNode; + out->MigrateNode = MigrateNode; fs::path root = nosEngine.Module->RootFolderPath; auto chViewerPath = (root / "Shaders" / "ChannelViewer.frag").generic_string(); diff --git a/Plugins/nosUtilities/Source/UtilitiesMain.cpp b/Plugins/nosUtilities/Source/UtilitiesMain.cpp index b0d8427d..826732ca 100644 --- a/Plugins/nosUtilities/Source/UtilitiesMain.cpp +++ b/Plugins/nosUtilities/Source/UtilitiesMain.cpp @@ -172,7 +172,7 @@ NOSAPI_ATTR nosResult NOSAPI_CALL nosExportPlugin(nosPluginFunctions* out) } // clang-format off outRenamedFrom[0] = NOS_NAME("nos.fb.ChannelViewerChannels"); outRenamedTo[0] = NOS_NAME("nos.utilities.ChannelViewerChannels"); - outRenamedFrom[1] = NOS_NAME("nos.fb.ChannelViewerFormats"); outRenamedTo[1] = NOS_NAME("nos.utilities.ChannelViewerFormats"); + outRenamedFrom[1] = NOS_NAME("nos.fb.ChannelViewerFormats"); outRenamedTo[1] = NOS_NAME("nos.mediaio.ColorSpace"); outRenamedFrom[2] = NOS_NAME("nos.fb.GradientKind"); outRenamedTo[2] = NOS_NAME("nos.utilities.GradientKind"); outRenamedFrom[3] = NOS_NAME("nos.fb.BlendMode"); outRenamedTo[3] = NOS_NAME("nos.utilities.BlendMode"); outRenamedFrom[4] = NOS_NAME("nos.fb.ResizeMethod"); outRenamedTo[4] = NOS_NAME("nos.utilities.ResizeMethod"); From e644526e9f556b350d4cbd4aef1a28f24fc30423 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 7 May 2026 18:59:37 +0300 Subject: [PATCH 20/30] nosMath: Add EulerToQuaternion and QuaternionToEuler nodes Introduce nosMath's first flatbuffer custom type (Math.fbs) and wire the plugin through nos_generate_flatbuffers so future nodes can share strongly-typed inputs/outputs. Adds two conversion nodes between Euler angles and quaternions, registered alongside the existing math nodes. --- Plugins/nosMath/CMakeLists.txt | 8 +- .../nosMath/Config/EulerToQuaternion.nosdef | 38 ++++++++ Plugins/nosMath/Config/Math.fbs | 10 ++ .../nosMath/Config/QuaternionToEuler.nosdef | 38 ++++++++ Plugins/nosMath/Math.noscfg | 8 +- Plugins/nosMath/Source/EulerToQuaternion.cpp | 97 +++++++++++++++++++ Plugins/nosMath/Source/Math.cpp | 12 +++ 7 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 Plugins/nosMath/Config/EulerToQuaternion.nosdef create mode 100644 Plugins/nosMath/Config/Math.fbs create mode 100644 Plugins/nosMath/Config/QuaternionToEuler.nosdef create mode 100644 Plugins/nosMath/Source/EulerToQuaternion.cpp diff --git a/Plugins/nosMath/CMakeLists.txt b/Plugins/nosMath/CMakeLists.txt index ee9ef718..bcbf2b2f 100644 --- a/Plugins/nosMath/CMakeLists.txt +++ b/Plugins/nosMath/CMakeLists.txt @@ -6,6 +6,10 @@ add_library(tinyexpr_cpp STATIC ${TINYEXPR_SOURCES}) target_include_directories(tinyexpr_cpp PUBLIC External/tinyexpr-cpp) nos_group_targets("tinyexpr_cpp" "External") -set(DEPENDENCIES ${NOS_PLUGIN_SDK_TARGET} tinyexpr_cpp) +set(GENERATED_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/Generated") +nos_generate_flatbuffers("${CMAKE_CURRENT_SOURCE_DIR}/Config" "${GENERATED_OUTPUT_DIR}" "cpp" "${NOS_SDK_DIR}/Types" nosMath_generated) -nos_add_plugin("nosMath" "${DEPENDENCIES}" "") +set(DEPENDENCIES ${NOS_PLUGIN_SDK_TARGET} tinyexpr_cpp nosMath_generated) +set(INCLUDE_FOLDERS "${GENERATED_OUTPUT_DIR}") + +nos_add_plugin("nosMath" "${DEPENDENCIES}" "${INCLUDE_FOLDERS}") diff --git a/Plugins/nosMath/Config/EulerToQuaternion.nosdef b/Plugins/nosMath/Config/EulerToQuaternion.nosdef new file mode 100644 index 00000000..fa40e5d7 --- /dev/null +++ b/Plugins/nosMath/Config/EulerToQuaternion.nosdef @@ -0,0 +1,38 @@ +{ + "nodes": [ + { + "class_name": "EulerToQuaternion", + "menu_info": { + "category": "Math|Linear Algebra", + "display_name": "Euler To Quaternion" + }, + "node": { + "class_name": "EulerToQuaternion", + "contents_type": "Job", + "description": "Converts an Euler-angle rotation (degrees) to a unit quaternion (x, y, z, w). The Order pin selects the intrinsic rotation order applied to the components of the input vec3 (e.g. ZYX means R = Rz(rot.z) * Ry(rot.y) * Rx(rot.x)). Default ZYX matches the FreeD/Track convention.", + "pins": [ + { + "name": "Euler", + "type_name": "nos.fb.vec3d", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Order", + "type_name": "nos.math.EulerOrder", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "ZYX", + "description": "Euler intrinsic rotation order applied to the (rot.x, rot.y, rot.z) components." + }, + { + "name": "Quaternion", + "type_name": "nos.fb.vec4d", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY" + } + ] + } + } + ] +} diff --git a/Plugins/nosMath/Config/Math.fbs b/Plugins/nosMath/Config/Math.fbs new file mode 100644 index 00000000..84c79eeb --- /dev/null +++ b/Plugins/nosMath/Config/Math.fbs @@ -0,0 +1,10 @@ +namespace nos.math; + +enum EulerOrder : ubyte { + ZYX = 0, + XYZ = 1, + YXZ = 2, + YZX = 3, + ZXY = 4, + XZY = 5, +} diff --git a/Plugins/nosMath/Config/QuaternionToEuler.nosdef b/Plugins/nosMath/Config/QuaternionToEuler.nosdef new file mode 100644 index 00000000..d08ffc7a --- /dev/null +++ b/Plugins/nosMath/Config/QuaternionToEuler.nosdef @@ -0,0 +1,38 @@ +{ + "nodes": [ + { + "class_name": "QuaternionToEuler", + "menu_info": { + "category": "Math|Linear Algebra", + "display_name": "Quaternion To Euler" + }, + "node": { + "class_name": "QuaternionToEuler", + "contents_type": "Job", + "description": "Converts a unit quaternion (x, y, z, w) to Euler angles (degrees). The Order pin selects the intrinsic rotation order extracted (e.g. ZYX yields rot.z = first rotation about Z, rot.y about Y, rot.x about X). Inverse of EulerToQuaternion when the same Order is used on both ends.", + "pins": [ + { + "name": "Quaternion", + "type_name": "nos.fb.vec4d", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Order", + "type_name": "nos.math.EulerOrder", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "ZYX", + "description": "Euler intrinsic rotation order extracted into the (rot.x, rot.y, rot.z) output components." + }, + { + "name": "Euler", + "type_name": "nos.fb.vec3d", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY" + } + ] + } + } + ] +} diff --git a/Plugins/nosMath/Math.noscfg b/Plugins/nosMath/Math.noscfg index de4791ee..55046e44 100644 --- a/Plugins/nosMath/Math.noscfg +++ b/Plugins/nosMath/Math.noscfg @@ -14,7 +14,9 @@ } ] }, - "custom_types": [], + "custom_types": [ + "Config/Math.fbs" + ], "node_definitions": [ "Config/Math.nosdef", "Config/Eval.nosdef", @@ -25,7 +27,9 @@ "Config/Random.nosdef", "Config/Lerp.nosdef", "Config/Vec3ToVec4.nosdef", - "Config/EmbedMat3ToMat4.nosdef" + "Config/EmbedMat3ToMat4.nosdef", + "Config/EulerToQuaternion.nosdef", + "Config/QuaternionToEuler.nosdef" ], "binary_path": "Binaries/nosMath", "third_party_software": [ diff --git a/Plugins/nosMath/Source/EulerToQuaternion.cpp b/Plugins/nosMath/Source/EulerToQuaternion.cpp new file mode 100644 index 00000000..94440a60 --- /dev/null +++ b/Plugins/nosMath/Source/EulerToQuaternion.cpp @@ -0,0 +1,97 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include +#include +#include +#include + +namespace nos::math +{ + +// Build a rotation matrix for the given intrinsic Euler order. +// In all cases, rot.x is the angle about X, rot.y about Y, rot.z about Z (radians). +// Order ZYX means R = Rz(rot.z) * Ry(rot.y) * Rx(rot.x), applied right-to-left to a point. +static glm::dmat4 EulerToMat(EulerOrder order, glm::dvec3 const& r) +{ + switch (order) + { + case EulerOrder::ZYX: return glm::eulerAngleZYX(r.z, r.y, r.x); + case EulerOrder::XYZ: return glm::eulerAngleXYZ(r.x, r.y, r.z); + case EulerOrder::YXZ: return glm::eulerAngleYXZ(r.y, r.x, r.z); + case EulerOrder::YZX: return glm::eulerAngleYZX(r.y, r.z, r.x); + case EulerOrder::ZXY: return glm::eulerAngleZXY(r.z, r.x, r.y); + case EulerOrder::XZY: return glm::eulerAngleXZY(r.x, r.z, r.y); + } + return glm::dmat4(1.0); +} + +static void MatToEuler(EulerOrder order, glm::dmat4 const& m, glm::dvec3& r) +{ + switch (order) + { + case EulerOrder::ZYX: glm::extractEulerAngleZYX(m, r.z, r.y, r.x); break; + case EulerOrder::XYZ: glm::extractEulerAngleXYZ(m, r.x, r.y, r.z); break; + case EulerOrder::YXZ: glm::extractEulerAngleYXZ(m, r.y, r.x, r.z); break; + case EulerOrder::YZX: glm::extractEulerAngleYZX(m, r.y, r.z, r.x); break; + case EulerOrder::ZXY: glm::extractEulerAngleZXY(m, r.z, r.x, r.y); break; + case EulerOrder::XZY: glm::extractEulerAngleXZY(m, r.x, r.z, r.y); break; + } +} + +struct EulerToQuaternionNodeContext : NodeContext +{ + using NodeContext::NodeContext; + + nosResult ExecuteNode(nosNodeExecuteParams* execParams) override + { + nos::NodeExecuteParams params(execParams); + auto* in = params.GetPinData(NOS_NAME("Euler")); + auto* order = params.GetPinData(NOS_NAME("Order")); + auto* out = params.GetPinData(NOS_NAME("Quaternion")); + + glm::dvec3 r = glm::radians(glm::dvec3(in->x(), in->y(), in->z())); + glm::dquat q(EulerToMat(*order, r)); + + out->mutate_x(q.x); + out->mutate_y(q.y); + out->mutate_z(q.z); + out->mutate_w(q.w); + return NOS_RESULT_SUCCESS; + } +}; + +void RegisterEulerToQuaternion(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("nos.math.EulerToQuaternion"), EulerToQuaternionNodeContext, fn) +} + +struct QuaternionToEulerNodeContext : NodeContext +{ + using NodeContext::NodeContext; + + nosResult ExecuteNode(nosNodeExecuteParams* execParams) override + { + nos::NodeExecuteParams params(execParams); + auto* in = params.GetPinData(NOS_NAME("Quaternion")); + auto* order = params.GetPinData(NOS_NAME("Order")); + auto* out = params.GetPinData(NOS_NAME("Euler")); + + glm::dquat q(in->w(), in->x(), in->y(), in->z()); + glm::dmat4 m = glm::mat4_cast(q); + glm::dvec3 r(0.0); + MatToEuler(*order, m, r); + + out->mutate_x(glm::degrees(r.x)); + out->mutate_y(glm::degrees(r.y)); + out->mutate_z(glm::degrees(r.z)); + return NOS_RESULT_SUCCESS; + } +}; + +void RegisterQuaternionToEuler(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("nos.math.QuaternionToEuler"), QuaternionToEulerNodeContext, fn) +} + +} // namespace nos::math diff --git a/Plugins/nosMath/Source/Math.cpp b/Plugins/nosMath/Source/Math.cpp index c307f2cf..a4bc6454 100644 --- a/Plugins/nosMath/Source/Math.cpp +++ b/Plugins/nosMath/Source/Math.cpp @@ -104,6 +104,8 @@ enum class MathNodeTypes : int { Or, Not, Random, + EulerToQuaternion, + QuaternionToEuler, Count }; @@ -168,6 +170,8 @@ void RegisterAnd(nosNodeFunctions*); void RegisterOr(nosNodeFunctions*); void RegisterNot(nosNodeFunctions*); void RegisterRandom(nosNodeFunctions*); +void RegisterEulerToQuaternion(nosNodeFunctions*); +void RegisterQuaternionToEuler(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outCount, nosNodeFunctions** outList) { @@ -281,6 +285,14 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outCount, nosNodeFunctions** o RegisterRandom(node); break; } + case MathNodeTypes::EulerToQuaternion: { + RegisterEulerToQuaternion(node); + break; + } + case MathNodeTypes::QuaternionToEuler: { + RegisterQuaternionToEuler(node); + break; + } default: break; } From bfe76da809bc4e3f5919ceab8e85e485555b0600 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Thu, 7 May 2026 18:59:43 +0300 Subject: [PATCH 21/30] nosTrack: Record fixed-step delta seconds in COLMAP timecode sidecar Capture TimingInfo.FixedStepTiming.DeltaSeconds during ExecuteNode and write it as the first non-comment line of the timecode sidecar (0 if the recording wasn't taken in fixed-step mode). Lets downstream tooling know the per-frame interval without having to derive it from timecodes. --- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 2ae9ef2d..4a4a4e48 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -54,6 +54,7 @@ struct RecordTrackCOLMAPContext : NodeContext bool LastRequestRecord = false; std::string LastError; std::vector Frames; + nosVec2u DeltaSeconds{}; // {numerator, denominator}; 0/0 if not in fixed-step mode RecordTrackCOLMAPContext(nosFbNodePtr node) : NodeContext(node) { @@ -172,6 +173,9 @@ struct RecordTrackCOLMAPContext : NodeContext { nos::NodeExecuteParams execParams(params); + if (params->TimingInfo.TimingMode == NOS_EXECUTION_TIMING_MODE_FIXED_STEP) + DeltaSeconds = params->TimingInfo.FixedStepTiming.DeltaSeconds; + // Pass through Track input to output nosBuffer trackBuf{}; for (size_t i = 0; i < params->PinCount; ++i) @@ -328,9 +332,12 @@ struct RecordTrackCOLMAPContext : NodeContext nosEngine.LogE("RecordTrackCOLMAP: Cannot open %s", nos::PathToUtf8(path).c_str()); return; } + double dt = (DeltaSeconds.y != 0) ? (double)DeltaSeconds.x / (double)DeltaSeconds.y : 0.0; file << "# Timecode sidecar paired with images.txt by IMAGE_ID.\n"; + file << "# First non-comment line: per-frame delta seconds (0 if recording wasn't in fixed-step timing).\n"; file << "# IMAGE_ID, TIMECODE, FRAME_NUMBER\n"; file << "# Number of entries: " << Frames.size() << "\n"; + file << std::setprecision(12) << dt << "\n"; for (size_t i = 0; i < Frames.size(); ++i) { const auto& f = Frames[i]; From cf5b65c0ad1a204aa035a01fd313ab0b4301fae5 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Fri, 8 May 2026 20:25:36 +0300 Subject: [PATCH 22/30] Add QuaternionMultiply node --- .../nosMath/Config/QuaternionMultiply.nosdef | 36 +++++++++++++++++ Plugins/nosMath/Math.noscfg | 3 +- Plugins/nosMath/Source/Math.cpp | 6 +++ Plugins/nosMath/Source/QuaternionMultiply.cpp | 39 +++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 Plugins/nosMath/Config/QuaternionMultiply.nosdef create mode 100644 Plugins/nosMath/Source/QuaternionMultiply.cpp diff --git a/Plugins/nosMath/Config/QuaternionMultiply.nosdef b/Plugins/nosMath/Config/QuaternionMultiply.nosdef new file mode 100644 index 00000000..fc162b41 --- /dev/null +++ b/Plugins/nosMath/Config/QuaternionMultiply.nosdef @@ -0,0 +1,36 @@ +{ + "nodes": [ + { + "class_name": "QuaternionMultiply", + "menu_info": { + "category": "Math|Linear Algebra", + "display_name": "Quaternion Multiply" + }, + "node": { + "class_name": "QuaternionMultiply", + "contents_type": "Job", + "description": "Hamilton product of two unit quaternions: Result = A * B (each as (x, y, z, w)). Composing rotations: A * B applies B first, then A. To rotate (conjugate) a quaternion Q by R, compute R * Q * conjugate(R).", + "pins": [ + { + "name": "A", + "type_name": "nos.fb.vec4d", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "B", + "type_name": "nos.fb.vec4d", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Result", + "type_name": "nos.fb.vec4d", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY" + } + ] + } + } + ] +} diff --git a/Plugins/nosMath/Math.noscfg b/Plugins/nosMath/Math.noscfg index 55046e44..5417aca6 100644 --- a/Plugins/nosMath/Math.noscfg +++ b/Plugins/nosMath/Math.noscfg @@ -29,7 +29,8 @@ "Config/Vec3ToVec4.nosdef", "Config/EmbedMat3ToMat4.nosdef", "Config/EulerToQuaternion.nosdef", - "Config/QuaternionToEuler.nosdef" + "Config/QuaternionToEuler.nosdef", + "Config/QuaternionMultiply.nosdef" ], "binary_path": "Binaries/nosMath", "third_party_software": [ diff --git a/Plugins/nosMath/Source/Math.cpp b/Plugins/nosMath/Source/Math.cpp index a4bc6454..1893cc8e 100644 --- a/Plugins/nosMath/Source/Math.cpp +++ b/Plugins/nosMath/Source/Math.cpp @@ -106,6 +106,7 @@ enum class MathNodeTypes : int { Random, EulerToQuaternion, QuaternionToEuler, + QuaternionMultiply, Count }; @@ -172,6 +173,7 @@ void RegisterNot(nosNodeFunctions*); void RegisterRandom(nosNodeFunctions*); void RegisterEulerToQuaternion(nosNodeFunctions*); void RegisterQuaternionToEuler(nosNodeFunctions*); +void RegisterQuaternionMultiply(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outCount, nosNodeFunctions** outList) { @@ -293,6 +295,10 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outCount, nosNodeFunctions** o RegisterQuaternionToEuler(node); break; } + case MathNodeTypes::QuaternionMultiply: { + RegisterQuaternionMultiply(node); + break; + } default: break; } diff --git a/Plugins/nosMath/Source/QuaternionMultiply.cpp b/Plugins/nosMath/Source/QuaternionMultiply.cpp new file mode 100644 index 00000000..395b4843 --- /dev/null +++ b/Plugins/nosMath/Source/QuaternionMultiply.cpp @@ -0,0 +1,39 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include +#include +#include + +namespace nos::math +{ + +struct QuaternionMultiplyNodeContext : NodeContext +{ + using NodeContext::NodeContext; + + nosResult ExecuteNode(nosNodeExecuteParams* execParams) override + { + nos::NodeExecuteParams params(execParams); + auto* a = params.GetPinData(NOS_NAME("A")); + auto* b = params.GetPinData(NOS_NAME("B")); + auto* out = params.GetPinData(NOS_NAME("Result")); + + glm::dquat qa(a->w(), a->x(), a->y(), a->z()); + glm::dquat qb(b->w(), b->x(), b->y(), b->z()); + glm::dquat qr = qa * qb; + + out->mutate_x(qr.x); + out->mutate_y(qr.y); + out->mutate_z(qr.z); + out->mutate_w(qr.w); + return NOS_RESULT_SUCCESS; + } +}; + +void RegisterQuaternionMultiply(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("nos.math.QuaternionMultiply"), QuaternionMultiplyNodeContext, fn) +} + +} // namespace nos::math From 7439287183505cfcc9fee0cb903b8add43b0b1bc Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Fri, 8 May 2026 20:26:10 +0300 Subject: [PATCH 23/30] Add Track modifier node and make recorder nodes respect COLMAP quat format --- Plugins/nosTrack/CMakeLists.txt | 2 +- .../Config/PlaybackTrackCOLMAP.nosdef | 24 ++--- .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 32 +++--- Plugins/nosTrack/Config/TrackTransform.nosdef | 54 ++++++++++ Plugins/nosTrack/Source/CoordinateFrameConv.h | 100 ++++++++++++++++++ .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 61 +++++------ Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 69 ++++++------ Plugins/nosTrack/Source/TrackMain.cpp | 5 + Plugins/nosTrack/Source/TrackTransform.cpp | 53 ++++++++++ Plugins/nosTrack/Track.noscfg | 3 +- Subsystems/nosTrackSubsystem/Config/Track.fbs | 20 ++-- 11 files changed, 317 insertions(+), 106 deletions(-) create mode 100644 Plugins/nosTrack/Config/TrackTransform.nosdef create mode 100644 Plugins/nosTrack/Source/CoordinateFrameConv.h create mode 100644 Plugins/nosTrack/Source/TrackTransform.cpp diff --git a/Plugins/nosTrack/CMakeLists.txt b/Plugins/nosTrack/CMakeLists.txt index 2b30615c..af2df47c 100644 --- a/Plugins/nosTrack/CMakeLists.txt +++ b/Plugins/nosTrack/CMakeLists.txt @@ -1,6 +1,6 @@ # Copyright MediaZ Teknoloji A.S. All Rights Reserved. -set(MODULE_DEPENDENCIES "nos.sys.track-1.0") +set(MODULE_DEPENDENCIES "nos.sys.track-1.1") set(dep_idx 0) foreach(module_name_version ${MODULE_DEPENDENCIES}) # module_name_version: - diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef index 54cb1cdc..460f02dc 100644 --- a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -12,7 +12,7 @@ "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.", + "description": "Loads camera track from COLMAP-spec cameras.txt + images.txt.\nReads world-to-camera poses in the COLMAP frame (RH, +X right, +Y down, +Z forward) and converts to the chosen TargetFrame.\nWhen an extras.txt sidecar is present, original Euler/FOV/sensor metadata is restored verbatim (no quaternion round-trip drift).", "pins": [ { "name": "InputDirectory", @@ -21,16 +21,16 @@ "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." + "description": "Directory with cameras.txt + images.txt (and optional timecodes.txt / extras.txt sidecars)." }, { - "name": "CoordinateSystem", - "display_name": "Coordinate System", - "type_name": "nos.sys.track.CoordinateSystem", + "name": "TargetFrame", + "display_name": "Target Frame", + "type_name": "nos.sys.track.CoordinateFrame", "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." + "data": "LH_ZUp_FwdX_RightY", + "description": "Coordinate frame of the produced Track.\nCOLMAP poses are converted into this frame.\nDefault matches FreeD / UE convention." }, { "name": "Mode", @@ -39,7 +39,7 @@ "show_as": "PROPERTY", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": "FrameIndex", - "description": "Selects how to index into the loaded frames. FrameIndex uses InFrameIndex; Timecode/FrameNumber look up via the timecodes.txt sidecar. The unused index pin is set to PASSIVE." + "description": "Selects how to index frames.\nFrameIndex uses InFrameIndex.\nTimecode / FrameNumber look up via timecodes.txt sidecar.\nThe unused index pin becomes PASSIVE." }, { "name": "InFrameIndex", @@ -56,7 +56,7 @@ "type_name": "string", "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", - "description": "Timecode string (HH:MM:SS:FF or HH:MM:SS;FF) to look up. Used when Mode=Timecode. Requires a timecodes.txt sidecar." + "description": "Timecode string (HH:MM:SS:FF) to look up. Used when Mode=Timecode. Requires timecodes.txt." }, { "name": "InFrameNumber", @@ -65,14 +65,14 @@ "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": 0, - "description": "Absolute frame number to look up. Used when Mode=FrameNumber. Requires a timecodes.txt sidecar." + "description": "Absolute frame number to look up. Used when Mode=FrameNumber. Requires timecodes.txt." }, { "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." + "description": "Track for the current frame, expressed in the TargetFrame convention." }, { "name": "OutFrameIndex", @@ -90,7 +90,7 @@ "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, - "description": "Total number of frames loaded." + "description": "Total frames loaded." } ], "functions": [ diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 34cb779f..25d70356 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -12,7 +12,7 @@ "display_name": "Record Track (COLMAP)", "contents_type": "Job", "always_execute": true, - "description": "Records camera tracking data each frame while the Record pin is true, then exports cameras.txt and images.txt in COLMAP format the moment recording stops, and clears the buffer. The Record pin is the single driver — wire it to an upstream Recording flag (e.g. from SonyVeniceANCParser) for automatic record-following capture. The MinOffFrames pin debounces brief drops in the Record signal so SDI bit flips don't end a take prematurely. 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.", + "description": "Records camera track data each frame while Record is true.\nOn falling edge (after MinOffFrames debounce) writes COLMAP-spec cameras.txt + images.txt and clears the buffer.\nIntrinsics come from FOV/sensor/distortion. Extrinsics are written as world-to-camera in the COLMAP frame (RH, +X right, +Y down, +Z forward).\nSet SourceFrame to match the convention of the connected Track.", "pins": [ { "name": "InTrack", @@ -20,7 +20,7 @@ "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." + "description": "Camera track to record. Interpreted in the SourceFrame convention (location, rotation Euler, FOV, sensor, lens distortion)." }, { "name": "Timecode", @@ -28,7 +28,7 @@ "type_name": "string", "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", - "description": "Optional SMPTE timecode (HH:MM:SS:FF or HH:MM:SS;FF) for the current frame. Written to a timecodes.txt sidecar when non-empty." + "description": "Optional SMPTE timecode (HH:MM:SS:FF). Written to timecodes.txt sidecar when non-empty." }, { "name": "FrameNumber", @@ -37,7 +37,7 @@ "show_as": "INPUT_PIN", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": 0, - "description": "Optional absolute frame number that pairs with Timecode (e.g. from ExtractTimecode). Written to the timecodes.txt sidecar." + "description": "Optional absolute frame number paired with Timecode. Written to timecodes.txt sidecar." }, { "name": "OutTrack", @@ -45,7 +45,7 @@ "type_name": "nos.sys.track.Track", "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", - "description": "Pass-through of the incoming Track data." + "description": "Pass-through of InTrack." }, { "name": "OutputDirectory", @@ -54,7 +54,7 @@ "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." + "description": "Where cameras.txt and images.txt are written when recording stops. Must be empty to start recording." }, { "name": "ImageResolution", @@ -66,16 +66,16 @@ "x": 1920, "y": 1080 }, - "description": "Image resolution in pixels (width, height). Used to compute focal length and principal point for COLMAP camera model." + "description": "Image WIDTH/HEIGHT in pixels. Used to compute focal length and principal point for cameras.txt." }, { - "name": "CoordinateSystem", - "display_name": "Coordinate System", - "type_name": "nos.sys.track.CoordinateSystem", + "name": "SourceFrame", + "display_name": "Source Frame", + "type_name": "nos.sys.track.CoordinateFrame", "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." + "data": "LH_ZUp_FwdX_RightY", + "description": "Coordinate frame of the connected Track.\nUsed to convert location and rotation into the COLMAP frame before writing.\nDefault matches FreeD / UE convention." }, { "name": "Record", @@ -83,7 +83,7 @@ "show_as": "PROPERTY", "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": false, - "description": "Drives recording: rising edge clears the buffer and starts capture; falling edge stops capture (subject to MinOffFrames debouncing) and exports the COLMAP files. Will fail to start if the output directory is not empty." + "description": "Drives recording.\nRising edge: clear buffer and start.\nFalling edge (after MinOffFrames): stop and write files.\nFails to start if OutputDirectory is non-empty." }, { "name": "MinOffFrames", @@ -93,7 +93,7 @@ "can_show_as": "INPUT_PIN_OR_PROPERTY", "data": 1, "min": "1", - "description": "Debounce: minimum number of consecutive frames with Record=false before recording actually stops. Default 1 = stop immediately. Use higher values (e.g. 5-15) to ride out short glitches in the upstream Record signal — useful when Record is wired to a camera-derived flag that can momentarily flip due to SDI bit errors." + "description": "Debounce: minimum consecutive Record=false frames before stopping. Default 1 = stop immediately. Use 5-15 to ride out short upstream glitches (e.g. SDI bit flips on a camera-derived flag)." }, { "name": "RecordingFrame", @@ -102,7 +102,7 @@ "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, - "description": "Current recording frame index. Outputs 0 when not recording." + "description": "Current recording frame index. 0 when not recording." }, { "name": "FrameCount", @@ -111,7 +111,7 @@ "show_as": "OUTPUT_PIN", "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, - "description": "Number of frames in the buffer." + "description": "Frames in the buffer." } ] } diff --git a/Plugins/nosTrack/Config/TrackTransform.nosdef b/Plugins/nosTrack/Config/TrackTransform.nosdef new file mode 100644 index 00000000..cb198734 --- /dev/null +++ b/Plugins/nosTrack/Config/TrackTransform.nosdef @@ -0,0 +1,54 @@ +{ + "nodes": [ + { + "class_name": "TrackTransform", + "menu_info": { + "category": "Track|Coordinate System", + "display_name": "Track Transform" + }, + "node": { + "class_name": "TrackTransform", + "contents_type": "Job", + "description": "Transforms a Track between coordinate frames.\nThe Source and Target enums select axis assignments, handedness, and the Euler convention used for the rotation field.\nLocation: basis-changed (Source -> Target), then multiplied by WorldScale (e.g. 0.01 for cm -> m, 100 for m -> cm).\nRotation: built in the source Euler convention, conjugated by the basis-change matrix, re-extracted in the target convention.\nOther Track fields (fov, focus, sensor_size, lens_distortion, ...) pass through unchanged.", + "pins": [ + { + "name": "In", + "type_name": "nos.sys.track.Track", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY" + }, + { + "name": "Source", + "type_name": "nos.sys.track.CoordinateFrame", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "LH_ZUp_FwdX_RightY", + "description": "Coordinate system convention of the input Track." + }, + { + "name": "Target", + "type_name": "nos.sys.track.CoordinateFrame", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "RH_YUp_FwdNegZ_RightX", + "description": "Coordinate system convention of the output Track." + }, + { + "name": "WorldScale", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1.0, + "description": "Uniform scale applied only to the output location after the basis change. Use to convert linear units (e.g. 0.01 for cm -> m, 100 for m -> cm). Does not affect rotation, fov, sensor size, focus, or lens distortion." + }, + { + "name": "Out", + "type_name": "nos.sys.track.Track", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY" + } + ] + } + } + ] +} diff --git a/Plugins/nosTrack/Source/CoordinateFrameConv.h b/Plugins/nosTrack/Source/CoordinateFrameConv.h new file mode 100644 index 00000000..777fea34 --- /dev/null +++ b/Plugins/nosTrack/Source/CoordinateFrameConv.h @@ -0,0 +1,100 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. +// Frame-conversion helpers shared by TrackTransform / RecordTrackCOLMAP / +// PlaybackTrackCOLMAP. Encodes per-frame Euler conventions and basis-change +// matrices to the COLMAP camera/world frame. +#pragma once + +#include "nosSysTrack/Track_generated.h" +#include +#include + +namespace nos::track::convention +{ + +using Frame = sys::track::CoordinateFrame; + +// Basis matrix S for a CoordinateFrame: maps semantic (forward, right, up) +// to engine coords (vx, vy, vz). v_engine = S * (forward, right, up). +// det(S) > 0 for left-handed frames, < 0 for right-handed (with this ordering). +inline glm::dmat3 BasisMatrix(Frame frame) +{ + switch (frame) + { + case Frame::LH_ZUp_FwdX_RightY: + // vx = forward, vy = right, vz = up. + return glm::dmat3(1.0); + case Frame::RH_YUp_FwdNegZ_RightX: + // vx = right, vy = up, vz = -forward. + return glm::dmat3( + glm::dvec3( 0.0, 0.0, -1.0), // M * (1,0,0) = forward column + glm::dvec3( 1.0, 0.0, 0.0), // M * (0,1,0) = right column + glm::dvec3( 0.0, 1.0, 0.0)); // M * (0,0,1) = up column + } + return glm::dmat3(1.0); +} + +// COLMAP camera/world frame: X right, Y down, Z forward (RH). +// Provided as a basis matrix in the same (forward, right, up) convention so +// it can be combined with BasisMatrix to build cross-frame conversions. +inline glm::dmat3 ColmapBasisMatrix() +{ + return glm::dmat3( + glm::dvec3( 0.0, 0.0, 1.0), // forward -> +Z + glm::dvec3( 1.0, 0.0, 0.0), // right -> +X + glm::dvec3( 0.0, -1.0, 0.0)); // up -> -Y (Y is down) +} + +// Build R_c2w in `frame` from Track.rotation Euler degrees. +inline glm::dmat3 EulerToMat(Frame frame, glm::dvec3 const& degRot) +{ + glm::dvec3 r = glm::radians(degRot); + switch (frame) + { + case Frame::LH_ZUp_FwdX_RightY: + // FRotator: rot.x = roll, rot.y = pitch, rot.z = yaw, intrinsic ZYX. + // UE sign convention has +pitch = look up and +roll = bank right via + // LH-rule rotations, equivalent to standard-RH Rz(yaw) * Ry(-pitch) * Rx(-roll). + return glm::dmat3(glm::eulerAngleZYX(r.z, -r.y, -r.x)); + case Frame::RH_YUp_FwdNegZ_RightX: + // rot.x = pitch, rot.y = yaw, rot.z = roll, intrinsic YXZ: + // R = Ry(yaw) * Rx(pitch) * Rz(roll), all standard-RH formulas. + return glm::dmat3(glm::eulerAngleYXZ(r.y, r.x, r.z)); + } + return glm::dmat3(1.0); +} + +// Inverse of EulerToMat: extract Euler degrees in `frame`'s convention. +// Output is packed into the (rot.x, rot.y, rot.z) Track layout for that frame. +inline glm::dvec3 MatToEuler(Frame frame, glm::dmat3 const& R) +{ + glm::dmat4 M(R); + double a = 0.0, b = 0.0, c = 0.0; + switch (frame) + { + case Frame::LH_ZUp_FwdX_RightY: + glm::extractEulerAngleZYX(M, a, b, c); // a=yaw, b=pitch, c=roll + // Negate pitch and roll back to UE sign convention; pack as (roll, pitch, yaw). + return glm::degrees(glm::dvec3(-c, -b, a)); + case Frame::RH_YUp_FwdNegZ_RightX: + glm::extractEulerAngleYXZ(M, a, b, c); // a=yaw, b=pitch, c=roll + // Pack as (pitch, yaw, roll). + return glm::degrees(glm::dvec3(b, a, c)); + } + return glm::dvec3(0.0); +} + +// Basis-change M from `frame` to COLMAP frame: M = S_colmap * S_frame^-1. +// For a vector: v_colmap = M * v_frame. +// For a rotation matrix: R_colmap = M * R_frame * M^-1. +inline glm::dmat3 BasisChangeToColmap(Frame frame) +{ + return ColmapBasisMatrix() * glm::inverse(BasisMatrix(frame)); +} + +// Inverse of BasisChangeToColmap. +inline glm::dmat3 BasisChangeFromColmap(Frame frame) +{ + return BasisMatrix(frame) * glm::inverse(ColmapBasisMatrix()); +} + +} // namespace nos::track::convention diff --git a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp index 50d405bb..56271a01 100644 --- a/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/PlaybackTrackCOLMAP.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include @@ -17,11 +16,13 @@ #include #include +#include "CoordinateFrameConv.h" + namespace nos::track { NOS_REGISTER_NAME_SPACED(Playback_InputDirectory, "InputDirectory"); -NOS_REGISTER_NAME_SPACED(Playback_CoordinateSystem, "CoordinateSystem"); +NOS_REGISTER_NAME_SPACED(Playback_TargetFrame, "TargetFrame"); NOS_REGISTER_NAME_SPACED(Playback_Mode, "Mode"); NOS_REGISTER_NAME_SPACED(Playback_InFrameIndex, "InFrameIndex"); NOS_REGISTER_NAME_SPACED(Playback_InTimecode, "InTimecode"); @@ -44,8 +45,8 @@ struct COLMAPCamera struct COLMAPImage { uint32_t Id = 0; - glm::quat Q{1, 0, 0, 0}; - glm::vec3 T{0}; + glm::quat Q{1, 0, 0, 0}; // R_w2c in COLMAP camera frame. + glm::vec3 T{0}; // t = -R_w2c * camera_world_position (COLMAP world frame). uint32_t CameraId = 0; }; @@ -74,7 +75,7 @@ struct ExtrasEntry struct PlaybackTrackCOLMAPContext : NodeContext { std::string InputDir; - sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; + convention::Frame TargetFrame = convention::Frame::LH_ZUp_FwdX_RightY; PlaybackTrackMode Mode = PlaybackTrackMode::FrameIndex; uint32_t FrameIndex = 0; std::string InTimecode; @@ -115,9 +116,9 @@ struct PlaybackTrackCOLMAPContext : NodeContext else UpdateStatus(); } - else if (pinName == NSN_Playback_CoordinateSystem) + else if (pinName == NSN_Playback_TargetFrame) { - CoordSys = *(sys::track::CoordinateSystem*)val.Data; + TargetFrame = *(convention::Frame*)val.Data; if (!InputDir.empty()) LoadFromDirectory(); } @@ -226,6 +227,15 @@ struct PlaybackTrackCOLMAPContext : NodeContext if (std::filesystem::exists(extrasPath)) ParseExtrasTxt(extrasPath, images.size(), extras); + // Inverse of RecordTrackCOLMAP::WriteImagesTxt: + // images.txt holds R_w2c in COLMAP frame, t = -R_w2c * pos_colmap. + // pos_colmap = -R_c2w_colmap * t (R_c2w_colmap = R_w2c^T) + // pos_target = M^-1 * pos_colmap + // R_c2w_target = M^-1 * R_c2w_colmap * M + // Track.rotation = MatToEuler(TargetFrame, R_c2w_target) + const glm::dmat3 Minv = convention::BasisChangeFromColmap(TargetFrame); + const glm::dmat3 M = glm::inverse(Minv); + for (size_t i = 0; i < images.size(); ++i) { auto& img = images[i]; @@ -233,11 +243,13 @@ struct PlaybackTrackCOLMAPContext : NodeContext auto camIt = cameras.find(img.CameraId); const ExtrasEntry* ex = (i < extras.size() && extras[i].Present) ? &extras[i] : nullptr; - // Position: invert COLMAP world-to-camera. Stable round-trip. - glm::mat3 R_w2c = glm::mat3_cast(img.Q); - glm::mat3 R_c2w = glm::transpose(R_w2c); - glm::vec3 C = -R_c2w * img.T; - trackData.location = reinterpret_cast(C); + glm::dmat3 R_w2c = glm::dmat3(glm::mat3_cast(img.Q)); + glm::dmat3 R_c2w_colmap = glm::transpose(R_w2c); + glm::dvec3 pos_colmap = -R_c2w_colmap * glm::dvec3(img.T); + + glm::dvec3 pos_target = Minv * pos_colmap; + glm::vec3 locF((float)pos_target.x, (float)pos_target.y, (float)pos_target.z); + trackData.location = reinterpret_cast(locF); // Rotation: prefer the original Euler from extras (avoids quaternion- // to-Euler ambiguity near gimbal lock); fall back to extracting from @@ -249,8 +261,10 @@ struct PlaybackTrackCOLMAPContext : NodeContext } else { - glm::vec3 euler = RotationMatrixToEuler(R_c2w, CoordSys); - trackData.rotation = reinterpret_cast(euler); + glm::dmat3 R_c2w_target = Minv * R_c2w_colmap * M; + glm::dvec3 eulerD = convention::MatToEuler(TargetFrame, R_c2w_target); + glm::vec3 eulerF((float)eulerD.x, (float)eulerD.y, (float)eulerD.z); + trackData.rotation = reinterpret_cast(eulerF); } if (camIt != cameras.end()) @@ -459,25 +473,6 @@ struct PlaybackTrackCOLMAPContext : NodeContext } } - // --- 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 --- bool ResolveFrameIndex(uint32_t& outIdx) diff --git a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp index 4a4a4e48..661d06e3 100644 --- a/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp +++ b/Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -13,12 +12,14 @@ #include #include +#include "CoordinateFrameConv.h" + namespace nos::track { NOS_REGISTER_NAME(OutputDirectory); NOS_REGISTER_NAME(ImageResolution); -NOS_REGISTER_NAME(CoordinateSystem); +NOS_REGISTER_NAME(SourceFrame); NOS_REGISTER_NAME(Record); NOS_REGISTER_NAME(MinOffFrames); NOS_REGISTER_NAME(FrameCount); @@ -27,7 +28,7 @@ NOS_REGISTER_NAME(RecordingFrame); struct RecordedFrame { glm::vec3 Location; - glm::vec3 Rotation; // Euler degrees (roll, tilt, pan) + glm::vec3 Rotation; // Euler degrees in the SourceFrame's convention. float FOV; float Zoom; float Focus; @@ -48,7 +49,7 @@ struct RecordTrackCOLMAPContext : NodeContext { std::string OutputDir; nosVec2u ImageResolution = {1920, 1080}; - sys::track::CoordinateSystem CoordSys = sys::track::CoordinateSystem::ZYX; + convention::Frame SourceFrame = convention::Frame::LH_ZUp_FwdX_RightY; bool Recording = false; uint32_t ConsecutiveOffFrames = 0; bool LastRequestRecord = false; @@ -115,8 +116,8 @@ struct RecordTrackCOLMAPContext : NodeContext } else if (pinName == NSN_ImageResolution) ImageResolution = *(nosVec2u*)val.Data; - else if (pinName == NSN_CoordinateSystem) - CoordSys = *(sys::track::CoordinateSystem*)val.Data; + else if (pinName == NSN_SourceFrame) + SourceFrame = *(convention::Frame*)val.Data; } bool CanStartRecording(std::string& outError) @@ -292,12 +293,17 @@ struct RecordTrackCOLMAPContext : NodeContext nosEngine.LogE("RecordTrackCOLMAP: Cannot open %s", nos::PathToUtf8(path).c_str()); return; } + const char* frameName = + SourceFrame == convention::Frame::LH_ZUp_FwdX_RightY ? "LH_ZUp_FwdX_RightY" + : SourceFrame == convention::Frame::RH_YUp_FwdNegZ_RightX ? "RH_YUp_FwdNegZ_RightX" + : "Unknown"; file << std::setprecision(12); file << "# Nodos Track sidecar paired with images.txt by IMAGE_ID.\n"; file << "# Carries fields that don't fit COLMAP's cameras.txt/images.txt:\n"; file << "# - sensor_size in mm (cameras.txt only stores pixel WIDTH/HEIGHT)\n"; file << "# - original Euler rotation in degrees (avoids quaternion round-trip drift)\n"; file << "# - nodos-only fields with no COLMAP equivalent\n"; + file << "# SourceFrame: " << frameName << " (Euler convention used for ROT_X, ROT_Y, ROT_Z below).\n"; file << "# IMAGE_ID, ZOOM, FOCUS, FOCUS_DISTANCE, RENDER_RATIO, NODAL_OFFSET, DISTORTION_SCALE, SENSOR_W_MM, SENSOR_H_MM, ROT_X, ROT_Y, ROT_Z\n"; file << "# Number of entries: " << Frames.size() << "\n"; for (size_t i = 0; i < Frames.size(); ++i) @@ -317,7 +323,7 @@ struct RecordTrackCOLMAPContext : NodeContext void WriteTimecodesTxt(const std::filesystem::path& outDir) { - // Skip the sidecar entirely if no frame carried a timecode — keeps the + // Skip the sidecar entirely if no frame carried a timecode -- keeps the // output minimal when the upstream graph isn't producing TC. bool any = false; for (auto& f : Frames) @@ -366,6 +372,8 @@ struct RecordTrackCOLMAPContext : NodeContext } file << std::setprecision(12); + file << "# COLMAP camera intrinsics. Standard format (colmap.github.io/format.html).\n"; + file << "# OPENCV model: PARAMS = fx, fy, cx, cy, k1, k2, p1, p2 (pixels).\n"; 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"; @@ -387,7 +395,6 @@ struct RecordTrackCOLMAPContext : NodeContext if (Frames[i].SensorSize.y > 0.0f) cy += Frames[i].CenterShift.y * ImageResolution.y / Frames[i].SensorSize.y; - // OPENCV model: fx, fy, cx, cy, k1, k2, p1, p2 float k1 = Frames[i].K1; float k2 = Frames[i].K2; @@ -397,23 +404,6 @@ struct RecordTrackCOLMAPContext : NodeContext } } - 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"; @@ -425,28 +415,35 @@ struct RecordTrackCOLMAPContext : NodeContext } file << std::setprecision(12); + file << "# COLMAP poses. Standard format (colmap.github.io/format.html).\n"; + file << "# Frame: RH, +X right, +Y down, +Z forward (camera looks along +Z).\n"; + file << "# (QW, QX, QY, QZ) is the world-to-camera rotation R_w2c.\n"; + file << "# (TX, TY, TZ) is the world-to-camera translation: t = -R_w2c * camera_world_position.\n"; + file << "# Recover camera position in the COLMAP world frame as: C = -R_w2c^T * t.\n"; 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"; + // M maps the SourceFrame to the COLMAP frame. Used to convert both the + // source-frame R_c2w and the source-frame camera position into COLMAP. + const glm::dmat3 M = convention::BasisChangeToColmap(SourceFrame); + const glm::dmat3 Minv = glm::inverse(M); + 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); + // Build R_c2w in the source frame, then conjugate by M to land in + // the COLMAP frame. Likewise frame the position. + glm::dmat3 R_c2w_src = convention::EulerToMat(SourceFrame, glm::dvec3(frame.Rotation)); + glm::dmat3 R_c2w_colmap = M * R_c2w_src * Minv; + glm::dvec3 pos_colmap = M * glm::dvec3(frame.Location); - // COLMAP translation: t = -R * C (camera center in world coords) - glm::vec3 t = -R_w2c * frame.Location; + glm::dmat3 R_w2c = glm::transpose(R_c2w_colmap); + glm::dquat q_w2c = glm::quat_cast(R_w2c); + glm::dvec3 t = -R_w2c * pos_colmap; - // 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 << " " diff --git a/Plugins/nosTrack/Source/TrackMain.cpp b/Plugins/nosTrack/Source/TrackMain.cpp index acca3be8..48b43fcc 100644 --- a/Plugins/nosTrack/Source/TrackMain.cpp +++ b/Plugins/nosTrack/Source/TrackMain.cpp @@ -17,6 +17,7 @@ enum TrackNode : int AddTrack, RecordTrackCOLMAP, PlaybackTrackCOLMAP, + TrackTransform, Count }; @@ -25,6 +26,7 @@ void RegisterController(nosNodeFunctions* functions); void RegisterAddTrack(nosNodeFunctions*); void RegisterRecordTrackCOLMAP(nosNodeFunctions*); void RegisterPlaybackTrackCOLMAP(nosNodeFunctions*); +void RegisterTrackTransform(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -52,6 +54,9 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou case TrackNode::PlaybackTrackCOLMAP: RegisterPlaybackTrackCOLMAP(node); break; + case TrackNode::TrackTransform: + RegisterTrackTransform(node); + break; } } return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosTrack/Source/TrackTransform.cpp b/Plugins/nosTrack/Source/TrackTransform.cpp new file mode 100644 index 00000000..0dabb1f1 --- /dev/null +++ b/Plugins/nosTrack/Source/TrackTransform.cpp @@ -0,0 +1,53 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include +#include +#include + +#include "CoordinateFrameConv.h" + +namespace nos::track +{ + +void RegisterTrackTransform(nosNodeFunctions* funcs) +{ + funcs->ClassName = NOS_NAME("TrackTransform"); + funcs->ExecuteNode = [](void*, nosNodeExecuteParams* params) { + auto pins = GetPinValues(params); + auto ids = GetPinIds(params); + + auto* inTrack = flatbuffers::GetMutableRoot(pins[NOS_NAME("In")]); + auto source = *static_cast(pins[NOS_NAME("Source")]); + auto target = *static_cast(pins[NOS_NAME("Target")]); + float worldScale = *static_cast(pins[NOS_NAME("WorldScale")]); + + nos::sys::track::TTrack out; + inTrack->UnPackTo(&out); + + const glm::dmat3 S_src = convention::BasisMatrix(source); + const glm::dmat3 S_tgt = convention::BasisMatrix(target); + const glm::dmat3 M = S_tgt * glm::inverse(S_src); + + // Location: basis change, then uniform world-scale. Other Track fields + // (rotation, fov, focus, sensor_size, lens_distortion, ...) are unaffected. + const auto& inLoc = *inTrack->location(); + glm::dvec3 loc(inLoc.x(), inLoc.y(), inLoc.z()); + glm::dvec3 outLoc = M * loc * static_cast(worldScale); + out.location.mutate_x(static_cast(outLoc.x)); + out.location.mutate_y(static_cast(outLoc.y)); + out.location.mutate_z(static_cast(outLoc.z)); + + // Rotation: build in source frame, conjugate by M, extract in target frame. + const auto& inRot = *inTrack->rotation(); + glm::dmat3 R_src = convention::EulerToMat(source, glm::dvec3(inRot.x(), inRot.y(), inRot.z())); + glm::dmat3 R_tgt = M * R_src * glm::transpose(M); + glm::dvec3 outRotDeg = convention::MatToEuler(target, R_tgt); + out.rotation.mutate_x(static_cast(outRotDeg.x)); + out.rotation.mutate_y(static_cast(outRotDeg.y)); + out.rotation.mutate_z(static_cast(outRotDeg.z)); + + return nosEngine.SetPinValue(ids[NOS_NAME("Out")], nos::Buffer::From(out)); + }; +} + +} // namespace nos::track diff --git a/Plugins/nosTrack/Track.noscfg b/Plugins/nosTrack/Track.noscfg index dc975d34..3810af05 100644 --- a/Plugins/nosTrack/Track.noscfg +++ b/Plugins/nosTrack/Track.noscfg @@ -18,7 +18,8 @@ "Config/UserTrack.nosdef", "Config/AddTrack.nosdef", "Config/RecordTrackCOLMAP.nosdef", - "Config/PlaybackTrackCOLMAP.nosdef" + "Config/PlaybackTrackCOLMAP.nosdef", + "Config/TrackTransform.nosdef" ], "custom_types": [ "Config/PlaybackMode.fbs" diff --git a/Subsystems/nosTrackSubsystem/Config/Track.fbs b/Subsystems/nosTrackSubsystem/Config/Track.fbs index 75e6e1ff..f36f2818 100644 --- a/Subsystems/nosTrackSubsystem/Config/Track.fbs +++ b/Subsystems/nosTrackSubsystem/Config/Track.fbs @@ -43,11 +43,17 @@ enum RotationSystem : uint { PRT = 5, } -enum EulerOrder : uint { - ZYX = 0, - XYZ = 1, - YXZ = 2, - YZX = 3, - ZXY = 4, - XZY = 5, +// World coordinate frame convention used by a Track endpoint. Encodes axis +// assignments to world-semantic directions (forward, right, up), the implied +// handedness, and the Euler convention for the Track.rotation field. +enum CoordinateFrame : ubyte { + // Left-handed, Z-up. +X forward, +Y right, +Z up. + // Rotation: rot.x = roll (X), rot.y = pitch (Y), rot.z = yaw (Z), + // intrinsic ZYX => R = Rz(yaw) * Ry(pitch) * Rx(roll). + LH_ZUp_FwdX_RightY = 0, + + // Right-handed, Y-up. +X right, +Y up, -Z forward. + // Rotation: rot.x = pitch (X), rot.y = yaw (Y), rot.z = roll (Z), + // intrinsic YXZ => R = Ry(yaw) * Rx(pitch) * Rz(roll). + RH_YUp_FwdNegZ_RightX = 1, } From 2bea82248767d8cf4dac788fd5f2472c505af63b Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Tue, 12 May 2026 10:21:23 +0300 Subject: [PATCH 24/30] Reorder pins in Track playback/record nodes --- .../Config/PlaybackTrackCOLMAP.nosdef | 14 ++++---- .../nosTrack/Config/RecordTrackCOLMAP.nosdef | 32 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef index 460f02dc..7f1604f2 100644 --- a/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/PlaybackTrackCOLMAP.nosdef @@ -67,13 +67,6 @@ "data": 0, "description": "Absolute frame number to look up. Used when Mode=FrameNumber. Requires timecodes.txt." }, - { - "name": "Track", - "type_name": "nos.sys.track.Track", - "show_as": "OUTPUT_PIN", - "can_show_as": "OUTPUT_PIN_ONLY", - "description": "Track for the current frame, expressed in the TargetFrame convention." - }, { "name": "OutFrameIndex", "display_name": "Frame Index", @@ -91,6 +84,13 @@ "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, "description": "Total frames loaded." + }, + { + "name": "Track", + "type_name": "nos.sys.track.Track", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "description": "Track for the current frame, expressed in the TargetFrame convention." } ], "functions": [ diff --git a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef index 25d70356..c26f52cb 100644 --- a/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef +++ b/Plugins/nosTrack/Config/RecordTrackCOLMAP.nosdef @@ -14,14 +14,6 @@ "always_execute": true, "description": "Records camera track data each frame while Record is true.\nOn falling edge (after MinOffFrames debounce) writes COLMAP-spec cameras.txt + images.txt and clears the buffer.\nIntrinsics come from FOV/sensor/distortion. Extrinsics are written as world-to-camera in the COLMAP frame (RH, +X right, +Y down, +Z forward).\nSet SourceFrame to match the convention of the connected Track.", "pins": [ - { - "name": "InTrack", - "display_name": "Track", - "type_name": "nos.sys.track.Track", - "show_as": "INPUT_PIN", - "can_show_as": "INPUT_PIN_OR_PROPERTY", - "description": "Camera track to record. Interpreted in the SourceFrame convention (location, rotation Euler, FOV, sensor, lens distortion)." - }, { "name": "Timecode", "display_name": "Timecode", @@ -39,14 +31,6 @@ "data": 0, "description": "Optional absolute frame number paired with Timecode. Written to timecodes.txt sidecar." }, - { - "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 InTrack." - }, { "name": "OutputDirectory", "display_name": "Output Directory", @@ -112,6 +96,22 @@ "can_show_as": "OUTPUT_PIN_ONLY", "data": 0, "description": "Frames in the buffer." + }, + { + "name": "InTrack", + "display_name": "Track", + "type_name": "nos.sys.track.Track", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "description": "Camera track to record. Interpreted in the SourceFrame convention (location, rotation Euler, FOV, sensor, lens distortion)." + }, + { + "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 InTrack." } ] } From 2e21bbbcf6f494aa087de0d4cd7bcb625bc7add1 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Mon, 18 May 2026 15:47:20 +0300 Subject: [PATCH 25/30] Add depth-of-field filter nodes --- Plugins/nosFilters/Config/DepthOfField.nosdef | 990 ++++++++++++++++++ .../nosFilters/Config/DirectionalDof.nosdef | 121 +++ Plugins/nosFilters/Filters.noscfg | 2 + .../nosFilters/Shaders/DirectionalDof.frag | 95 ++ 4 files changed, 1208 insertions(+) create mode 100644 Plugins/nosFilters/Config/DepthOfField.nosdef create mode 100644 Plugins/nosFilters/Config/DirectionalDof.nosdef create mode 100644 Plugins/nosFilters/Shaders/DirectionalDof.frag diff --git a/Plugins/nosFilters/Config/DepthOfField.nosdef b/Plugins/nosFilters/Config/DepthOfField.nosdef new file mode 100644 index 00000000..1db5a70f --- /dev/null +++ b/Plugins/nosFilters/Config/DepthOfField.nosdef @@ -0,0 +1,990 @@ +{ "nodes": [ + { + "class_name": "nos.filters.DepthOfField", + "node": { + "id": "5899940c-437e-4f71-b119-bb80fb5d1e1a", + "name": "DepthOfField", + "class_name": "nos.filters.DepthOfField", + "pins": [ + { + "id": "1950c2e6-a0f6-485b-8a02-bded8a2f6ed5", + "name": "Depth", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED" + }, + "def": { + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "PortalPin", + "contents": { "source_id": "74a1bfd0-4f2d-447b-945c-8d0cb67a2120" } + }, + { + "id": "e0b8f433-212f-48f6-ba4f-c8a194e1a707", + "name": "FocusDistance", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 5.0, + "min": 0.0, + "max": 1000.0, + "def": 5.0, + "step": 10.0, + "contents_type": "PortalPin", + "contents": { "source_id": "e709c7b4-9a59-4546-be53-0dc51abc5605" } + }, + { + "id": "68187c92-92f3-40d0-8b24-df6f33f9f649", + "name": "FocusRange", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 2.0, + "min": 0.01, + "max": 1000.0, + "def": 2.0, + "step": 9.9999, + "contents_type": "PortalPin", + "contents": { "source_id": "534a26e9-1ebd-4ed2-89fb-bdf5d34b6ec1" } + }, + { + "id": "42554a0a-2d70-4ec4-a2ea-594ad71559f3", + "name": "MaxRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 16.0, + "min": 0.0, + "max": 128.0, + "def": 16.0, + "step": 1.28, + "contents_type": "PortalPin", + "contents": { "source_id": "63f77504-73aa-4b89-8849-65e27649b272" } + }, + { + "id": "312c4450-a4ad-4690-ba3d-afcbc93da6eb", + "name": "MinRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 0.5, + "min": 0.0, + "max": 8.0, + "def": 0.5, + "step": 0.08, + "contents_type": "PortalPin", + "contents": { "source_id": "97561978-6da1-4a33-a6bc-c654008a8261" } + }, + { + "id": "ce6c0d45-8ce1-47ef-bd73-addda06d826e", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "def": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "PortalPin", + "contents": { "source_id": "c278680b-43b5-40ce-b1af-a4551c2e58f0" } + }, + { + "id": "2be9d3ba-9386-43b0-ae1c-58168be2a289", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED", + "filtering": "LINEAR" + }, + "def": { + "filtering": "LINEAR" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "PortalPin", + "contents": { "source_id": "479248dc-200d-4a4d-87d2-f2c7c77f667f" } + } + ], + "pos": { "x": 0.0, "y": 0.0 }, + "contents_type": "Graph", + "contents": { "nodes": [ + { + "id": "393281e0-2cb8-4b90-a98e-a8e708719229", + "name": "Output", + "class_name": "nos.internal.GraphOutput", + "pins": [ + { + "id": "2e4ec877-e014-49ae-ae1d-881a0e4d1ac5", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "def": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "c278680b-43b5-40ce-b1af-a4551c2e58f0", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "referred_by": [ + "ce6c0d45-8ce1-47ef-bd73-addda06d826e" + ], + "def": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" }, + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 1329.0, "y": 1025.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "6a261add-ff1c-49ba-b9b7-a3bbad8e1fb3", + "name": "Directional DoF (1)", + "class_name": "nos.filters.DirectionalDof", + "pins": [ + { + "id": "b1b03fce-6863-42e6-a78a-260743b5441d", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET", + "filtering": "LINEAR" + }, + "def": { + "filtering": "LINEAR" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "14aab6c6-10ce-4a39-9c6f-8c5633fe59e2", + "name": "Depth", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED" + }, + "def": { + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "d6a91b7a-b576-487b-bd2c-89fee90a37d1", + "name": "FocusDistance", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 7.4, + "min": 0.0, + "max": 1000.0, + "def": 5.0, + "step": 10.0, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "55bffdc3-e0fa-4c0f-9ead-5a3b96c232bf", + "name": "FocusRange", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 3.1, + "min": 0.01, + "max": 1000.0, + "def": 2.0, + "step": 9.9999, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "bdc5ae5a-10cc-4c3c-b013-573a64bd8ec6", + "name": "MaxRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 5.0, + "min": 0.0, + "max": 128.0, + "def": 16.0, + "step": 1.28, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "d77d3716-69f5-4c5d-a342-414dc11597fb", + "name": "MinRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 0.0, + "min": 0.0, + "max": 8.0, + "def": 0.5, + "step": 0.08, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "ad25df82-1942-4f9f-a062-c072261a2d92", + "name": "BackgroundIsFar", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 1.0, + "min": 0.0, + "max": 1.0, + "def": 1.0, + "step": 0.01, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "aaff92e1-63fe-4253-8edb-1f34a76019c9", + "name": "Direction", + "type_name": "nos.fb.vec2", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { "x": 0.0, "y": 1.0 }, + "min": { "x": -1.0, "y": -1.0 }, + "max": { "x": 1.0, "y": 1.0 }, + "def": { "x": 1.0, "y": 0.0 }, + "step": 0.02, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "ad6603e0-2b1d-4bf6-a1d1-af0fc05978a2", + "name": "SampleCount", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 12.0, + "min": 1.0, + "max": 64.0, + "def": 12.0, + "step": 0.63, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "0ef0f439-9766-4957-8931-a02ce1019bd1", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "def": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 1129.0, "y": 1073.0 }, + "contents_type": "Job", + "contents": { "type": "nos.sys.vulkan.GPUNode", "options": { "shader": "Shaders/DirectionalDof.frag" } }, + "function_category": "Default Node", + "description": "1D depth-aware blur. CoC is computed per pixel from a linear view-space Z input. Chain two instances along (1,0) and (0,1) for a separable disc bokeh.", + "plugin_version": { "major": 1, "minor": 7, "patch": 0 } + }, + { + "id": "deac982f-b51b-4ae0-b6c6-9b2998d3e5a9", + "name": "MaxRadius", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "d5387b2e-f8c6-4b2e-8a42-a11eed779a1d", + "name": "Output", + "type_name": "float", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": 5.0, + "min": 0.0, + "max": 128.0, + "def": 16.0, + "step": 1.28, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "63f77504-73aa-4b89-8849-65e27649b272", + "name": "Input", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 16.0, + "referred_by": [ + "42554a0a-2d70-4ec4-a2ea-594ad71559f3" + ], + "min": 0.0, + "max": 128.0, + "def": 16.0, + "step": 1.28, + "meta_data_map": [ + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1250.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "af576b2d-dde0-4d7b-86fc-37cb9f97b49e", + "name": "Directional DoF", + "class_name": "nos.filters.DirectionalDof", + "pins": [ + { + "id": "9e368dde-bb31-44d8-aaad-782e92fe2366", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED", + "filtering": "LINEAR" + }, + "def": { + "filtering": "LINEAR" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "7e88a91a-1eca-4cc5-8dce-7c4aca61368d", + "name": "Depth", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED" + }, + "def": { + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "c1b814c4-e424-40a0-99d6-0437d948d1d7", + "name": "FocusDistance", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 7.4, + "min": 0.0, + "max": 1000.0, + "def": 5.0, + "step": 10.0, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "78893474-3dfc-4a36-b897-77760ba19c8c", + "name": "FocusRange", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 3.1, + "min": 0.01, + "max": 1000.0, + "def": 2.0, + "step": 9.9999, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "4f471215-bebf-49be-a6e4-909c394d1f1a", + "name": "MaxRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 5.0, + "min": 0.0, + "max": 128.0, + "def": 16.0, + "step": 1.28, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "05132381-cf95-4253-9fc2-e87f84b70dd8", + "name": "MinRadius", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 0.0, + "min": 0.0, + "max": 8.0, + "def": 0.5, + "step": 0.08, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "2b52da00-b45d-41ae-a1ec-c88566879043", + "name": "BackgroundIsFar", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 1.0, + "min": 0.0, + "max": 1.0, + "def": 1.0, + "step": 0.01, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "ac684e31-1a90-462f-8b64-2b368a93b563", + "name": "Direction", + "type_name": "nos.fb.vec2", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { "x": 1.0, "y": 0.0 }, + "min": { "x": -1.0, "y": -1.0 }, + "max": { "x": 1.0, "y": 1.0 }, + "def": { "x": 1.0, "y": 0.0 }, + "step": 0.02, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "b8527f03-5c5c-4a41-b485-fa05e0f50cb1", + "name": "SampleCount", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 12.0, + "min": 1.0, + "max": 64.0, + "def": 12.0, + "step": 0.63, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "8f4d23a7-3b94-4a1c-ba14-d1ce47e92acd", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "def": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 855.0, "y": 977.0 }, + "contents_type": "Job", + "contents": { "type": "nos.sys.vulkan.GPUNode", "options": { "shader": "Shaders/DirectionalDof.frag" } }, + "function_category": "Default Node", + "description": "1D depth-aware blur. CoC is computed per pixel from a linear view-space Z input. Chain two instances along (1,0) and (0,1) for a separable disc bokeh.", + "plugin_version": { "major": 1, "minor": 7, "patch": 0 } + }, + { + "id": "8b497dab-5466-4d32-a440-125976e3a3ee", + "name": "Depth", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "9587b7b1-8fc7-437b-9459-ee73f90de097", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED" + }, + "def": { + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "74a1bfd0-4f2d-447b-945c-8d0cb67a2120", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED" + }, + "referred_by": [ + "1950c2e6-a0f6-485b-8a02-bded8a2f6ed5" + ], + "def": { + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" }, + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1025.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "9813ee9d-1f75-4554-9f9c-b9ecafc2e9fe", + "name": "FocusDistance", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "7c60934b-ba19-4faf-9923-411511649cd0", + "name": "Output", + "type_name": "float", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": 7.4, + "min": 0.0, + "max": 1000.0, + "def": 5.0, + "step": 10.0, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "e709c7b4-9a59-4546-be53-0dc51abc5605", + "name": "Input", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 5.0, + "referred_by": [ + "e0b8f433-212f-48f6-ba4f-c8a194e1a707" + ], + "min": 0.0, + "max": 1000.0, + "def": 5.0, + "step": 10.0, + "meta_data_map": [ + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1100.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "2c0861b9-e416-4741-b56d-8dfa81c49516", + "name": "FocusRange", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "6a933bab-7bf6-4388-b990-abd1b9729e64", + "name": "Output", + "type_name": "float", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": 3.1, + "min": 0.01, + "max": 1000.0, + "def": 2.0, + "step": 9.9999, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "534a26e9-1ebd-4ed2-89fb-bdf5d34b6ec1", + "name": "Input", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 2.0, + "referred_by": [ + "68187c92-92f3-40d0-8b24-df6f33f9f649" + ], + "min": 0.01, + "max": 1000.0, + "def": 2.0, + "step": 9.9999, + "meta_data_map": [ + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1175.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "3951aaae-16df-4b07-b1a9-b8b2a01b19c7", + "name": "MinRadius", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "f0feee29-3782-49fe-a834-94e2b57916a8", + "name": "Output", + "type_name": "float", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": 0.0, + "min": 0.0, + "max": 8.0, + "def": 0.5, + "step": 0.08, + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "97561978-6da1-4a33-a6bc-c654008a8261", + "name": "Input", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": 0.5, + "referred_by": [ + "312c4450-a4ad-4690-ba3d-afcbc93da6eb" + ], + "min": 0.0, + "max": 8.0, + "def": 0.5, + "step": 0.08, + "meta_data_map": [ + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1325.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + }, + { + "id": "a1d16ddd-0144-4daa-97b2-e9b3b019c8c1", + "name": "Input", + "class_name": "nos.internal.GraphInput", + "pins": [ + { + "id": "c271ac23-2923-45c2-b262-b654455a93c3", + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED", + "filtering": "LINEAR" + }, + "def": { + "filtering": "LINEAR" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + }, + { + "id": "479248dc-200d-4a4d-87d2-f2c7c77f667f", + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + }, + "data": { + "resolution": "HD", + "width": 1920, + "height": 1080, + "format": "R16G16B16A16_SFLOAT", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED", + "filtering": "LINEAR" + }, + "referred_by": [ + "2be9d3ba-9386-43b0-ae1c-58168be2a289" + ], + "def": { + "filtering": "LINEAR" + }, + "advanced_property": true, + "meta_data_map": [ + { "key": "AdvancedProperty", "value": "true" }, + { "key": "PinHidden", "value": "true" } + ], + "contents_type": "JobPin", + "contents": { } + } + ], + "pos": { "x": 655.0, "y": 1400.0 }, + "contents_type": "Job", + "contents": { "type": "" }, + "function_category": "Default Node", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + } + ], "connections": [ + { "from": "8f4d23a7-3b94-4a1c-ba14-d1ce47e92acd", "to": "b1b03fce-6863-42e6-a78a-260743b5441d", "id": "83839676-0760-4699-ae80-c0a789e273d8" }, + { "from": "f0feee29-3782-49fe-a834-94e2b57916a8", "to": "d77d3716-69f5-4c5d-a342-414dc11597fb", "id": "4c05135f-6001-4679-b39c-b248559ae56d" }, + { "from": "9587b7b1-8fc7-437b-9459-ee73f90de097", "to": "14aab6c6-10ce-4a39-9c6f-8c5633fe59e2", "id": "231cdfe5-7ac7-4013-9d20-68d5af8509b7" }, + { "from": "7c60934b-ba19-4faf-9923-411511649cd0", "to": "d6a91b7a-b576-487b-bd2c-89fee90a37d1", "id": "1cdaef73-876c-472a-97ff-04bf1f01348e" }, + { "from": "c271ac23-2923-45c2-b262-b654455a93c3", "to": "9e368dde-bb31-44d8-aaad-782e92fe2366", "id": "231fc88c-a52e-48d0-a6ee-8c2fdfe3ef0d" }, + { "from": "6a933bab-7bf6-4388-b990-abd1b9729e64", "to": "55bffdc3-e0fa-4c0f-9ead-5a3b96c232bf", "id": "7c1cae59-5834-420e-9d3d-e4767f6c3273" }, + { "from": "d5387b2e-f8c6-4b2e-8a42-a11eed779a1d", "to": "bdc5ae5a-10cc-4c3c-b013-573a64bd8ec6", "id": "d74bdb3a-8c8c-4f82-8038-01a237e27a89" }, + { "from": "0ef0f439-9766-4957-8931-a02ce1019bd1", "to": "2e4ec877-e014-49ae-ae1d-881a0e4d1ac5", "id": "353cc954-d098-417a-8331-357b879ba654" }, + { "from": "9587b7b1-8fc7-437b-9459-ee73f90de097", "to": "7e88a91a-1eca-4cc5-8dce-7c4aca61368d", "id": "b126f4c4-d748-46f2-be51-ce1c778c0c4b" }, + { "from": "7c60934b-ba19-4faf-9923-411511649cd0", "to": "c1b814c4-e424-40a0-99d6-0437d948d1d7", "id": "fc25a2f4-0af4-49ae-9052-133a76cfc044" }, + { "from": "6a933bab-7bf6-4388-b990-abd1b9729e64", "to": "78893474-3dfc-4a36-b897-77760ba19c8c", "id": "f6ba18f8-0ef1-42db-a774-c4b02aa78fac" }, + { "from": "d5387b2e-f8c6-4b2e-8a42-a11eed779a1d", "to": "4f471215-bebf-49be-a6e4-909c394d1f1a", "id": "afd9d7ff-f9e2-4a67-b874-2cfb2f870447" }, + { "from": "f0feee29-3782-49fe-a834-94e2b57916a8", "to": "05132381-cf95-4253-9fc2-e87f84b70dd8", "id": "82919455-4a51-490a-8ab2-201952d2e126" } + ] }, + "function_category": "Default Node", + "display_name": "Depth of Field", + "plugin_version": { "major": 0, "minor": 0, "patch": 0 } + } + } + ] } diff --git a/Plugins/nosFilters/Config/DirectionalDof.nosdef b/Plugins/nosFilters/Config/DirectionalDof.nosdef new file mode 100644 index 00000000..427e4385 --- /dev/null +++ b/Plugins/nosFilters/Config/DirectionalDof.nosdef @@ -0,0 +1,121 @@ +{ + "nodes": [ + { + "class_name": "DirectionalDof", + "menu_info": { + "category": "Filters", + "display_name": "Directional DoF" + }, + "node": { + "class_name": "DirectionalDof", + "name": "Directional DoF", + "description": "1D depth-aware blur. CoC is computed per pixel from a linear view-space Z input. Chain two instances along (1,0) and (0,1) for a separable disc bokeh.", + "contents_type": "Job", + "contents": { + "type": "nos.sys.vulkan.GPUNode", + "options": { + "shader": "Shaders/DirectionalDof.frag" + } + }, + "pins": [ + { + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "filtering": "LINEAR" + } + }, + { + "name": "Depth", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "filtering": "NEAREST" + } + }, + { + "name": "FocusDistance", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 5.0, + "min": 0.0, + "max": 1000.0 + }, + { + "name": "FocusRange", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 2.0, + "min": 0.01, + "max": 1000.0 + }, + { + "name": "MaxRadius", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 16.0, + "min": 0.0, + "max": 128.0 + }, + { + "name": "MinRadius", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.5, + "min": 0.0, + "max": 8.0 + }, + { + "name": "BackgroundIsFar", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1.0, + "min": 0.0, + "max": 1.0 + }, + { + "name": "Direction", + "type_name": "nos.fb.vec2", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "x": 1.0, + "y": 0.0 + }, + "min": { + "x": -1.0, + "y": -1.0 + }, + "max": { + "x": 1.0, + "y": 1.0 + } + }, + { + "name": "SampleCount", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 12.0, + "min": 1.0, + "max": 64.0 + }, + { + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY" + } + ] + } + } + ] +} diff --git a/Plugins/nosFilters/Filters.noscfg b/Plugins/nosFilters/Filters.noscfg index 072c6ce0..18bac701 100644 --- a/Plugins/nosFilters/Filters.noscfg +++ b/Plugins/nosFilters/Filters.noscfg @@ -27,6 +27,8 @@ "Config/Diff.nosdef", "Config/GaussianBlur.nosdef", "Config/DirectionalBlur.nosdef", + "Config/DirectionalDof.nosdef", + "Config/DepthOfField.nosdef", "Config/KawaseLightStreak.nosdef", "Config/Kuwahara.nosdef", "Config/PremultiplyAlpha.nosdef", diff --git a/Plugins/nosFilters/Shaders/DirectionalDof.frag b/Plugins/nosFilters/Shaders/DirectionalDof.frag new file mode 100644 index 00000000..308cfcc7 --- /dev/null +++ b/Plugins/nosFilters/Shaders/DirectionalDof.frag @@ -0,0 +1,95 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. +// Directional depth-of-field pass. +// Computes circle-of-confusion (CoC) per pixel from a linear view-space Z input, +// then does a 1D weighted gather along Direction. Chain two instances +// (Direction = (1,0) and Direction = (0,1)) for a separable approximation of +// disc bokeh; visually close to a gaussian bokeh and cheap. + +#version 450 + +#define MASK_THRESHOLD 0.001 + +layout(binding = 0) uniform sampler2D Input; +layout(binding = 1) uniform sampler2D Depth; +layout(binding = 2) uniform DirectionalDofParams +{ + // Focus distance in the same units as the Depth input (linear view-space Z). + float FocusDistance; + // Distance from focus where CoC reaches MaxRadius. + // Smaller value = sharper focus falloff; larger = gentler. + float FocusRange; + // Maximum CoC radius in pixels. + float MaxRadius; + // 0 = treat zero depth as "no info, keep sharp"; 1 = treat zero depth as far. + float BackgroundIsFar; + vec2 Direction; + // Optional: clamp CoC near the focus plane to avoid noise; raise to skip tiny blurs. + float MinRadius; + // Sample count along the direction (one side; total taps = 2*N+1). Higher = smoother. + float SampleCount; +} +Params; + +layout(location = 0) out vec4 rt; +layout(location = 0) in vec2 uv; + +float CocFromDepth(float Z) +{ + // Treat Z<=0 (no depth signal) as either "near focus" (BackgroundIsFar=0) + // or as far plane (BackgroundIsFar=1). Picking far avoids halos around empty regions. + if (Z <= 0.0) + Z = mix(Params.FocusDistance, Params.FocusDistance + Params.FocusRange * 4.0, Params.BackgroundIsFar); + + float D = abs(Z - Params.FocusDistance); + float Coc = D / max(Params.FocusRange, 1e-4); + Coc = clamp(Coc * Params.MaxRadius, 0.0, Params.MaxRadius); + return Coc; +} + +void main() +{ + vec2 TextureSize = textureSize(Input, 0); + vec2 TexelSize = 1.0 / TextureSize; + + vec4 CenterColor = texture(Input, uv); + float CenterZ = texture(Depth, uv).r; + float CenterCoC = CocFromDepth(CenterZ); + + if (CenterCoC <= Params.MinRadius || Params.MaxRadius < MASK_THRESHOLD) + { + rt = CenterColor; + return; + } + + vec2 Dir = normalize(Params.Direction); + + int N = int(max(1.0, Params.SampleCount)); + float RadiusPx = CenterCoC; + float Step = RadiusPx / float(N); + + // Box-weighted average; for separable-2D this gives a soft disc. + // CoC-clamping per sample prevents fragments in focus from bleeding outward. + vec4 Accum = CenterColor; + float Weight = 1.0; + + for (int i = 1; i <= N; ++i) + { + float T = float(i) * Step; + vec2 Ofs = Dir * T * TexelSize; + + vec4 SPos = texture(Input, uv + Ofs); + float ZPos = texture(Depth, uv + Ofs).r; + float CocPos = CocFromDepth(ZPos); + float WPos = Step <= CocPos ? 1.0 : 0.0; + + vec4 SNeg = texture(Input, uv - Ofs); + float ZNeg = texture(Depth, uv - Ofs).r; + float CocNeg = CocFromDepth(ZNeg); + float WNeg = Step <= CocNeg ? 1.0 : 0.0; + + Accum += SPos * WPos + SNeg * WNeg; + Weight += WPos + WNeg; + } + + rt = Accum / Weight; +} From 187675d8c82d30e3f7bee1c1ef2698aa9ed18d4a Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Mon, 18 May 2026 15:58:29 +0300 Subject: [PATCH 26/30] Bump nos.filters to 1.8.0 --- Plugins/nosFilters/Filters.noscfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/nosFilters/Filters.noscfg b/Plugins/nosFilters/Filters.noscfg index 18bac701..c9ebb2ce 100644 --- a/Plugins/nosFilters/Filters.noscfg +++ b/Plugins/nosFilters/Filters.noscfg @@ -2,7 +2,7 @@ "info": { "id": { "name": "nos.filters", - "version": "1.7.0" + "version": "1.8.0" }, "display_name": "Filters", "description": "Collection of image filters.", From 17a5edc1e38b38c21beefef381d8b9dd3cbc593c Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Mon, 18 May 2026 18:13:23 +0300 Subject: [PATCH 27/30] Add BokehDof and BokehShape nodes --- Plugins/nosFilters/Config/BokehDof.nosdef | 121 ++++++++++++++++++++ Plugins/nosFilters/Config/BokehShape.nosdef | 93 +++++++++++++++ Plugins/nosFilters/Filters.noscfg | 2 + Plugins/nosFilters/Shaders/BokehDof.frag | 105 +++++++++++++++++ Plugins/nosFilters/Shaders/BokehShape.frag | 77 +++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 Plugins/nosFilters/Config/BokehDof.nosdef create mode 100644 Plugins/nosFilters/Config/BokehShape.nosdef create mode 100644 Plugins/nosFilters/Shaders/BokehDof.frag create mode 100644 Plugins/nosFilters/Shaders/BokehShape.frag diff --git a/Plugins/nosFilters/Config/BokehDof.nosdef b/Plugins/nosFilters/Config/BokehDof.nosdef new file mode 100644 index 00000000..df5b08fb --- /dev/null +++ b/Plugins/nosFilters/Config/BokehDof.nosdef @@ -0,0 +1,121 @@ +{ + "nodes": [ + { + "class_name": "BokehDof", + "menu_info": { + "category": "Filters", + "display_name": "Bokeh DoF" + }, + "node": { + "class_name": "BokehDof", + "name": "Bokeh DoF", + "description": "Single-pass 2D depth-of-field. CoC is computed from a linear view-space Z input; samples are gathered on a Vogel disc weighted by the BokehShape kernel texture, so bokeh takes the shape painted into BokehShape.", + "contents_type": "Job", + "contents": { + "type": "nos.sys.vulkan.GPUNode", + "options": { + "shader": "Shaders/BokehDof.frag" + } + }, + "pins": [ + { + "name": "Input", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "filtering": "LINEAR" + } + }, + { + "name": "Depth", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "filtering": "NEAREST" + } + }, + { + "name": "BokehShape", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { + "filtering": "LINEAR" + } + }, + { + "name": "FocusDistance", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 5.0, + "min": 0.0, + "max": 1000.0 + }, + { + "name": "FocusRange", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 2.0, + "min": 0.01, + "max": 1000.0 + }, + { + "name": "MaxRadius", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 16.0, + "min": 0.0, + "max": 128.0 + }, + { + "name": "MinRadius", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.5, + "min": 0.0, + "max": 8.0 + }, + { + "name": "BackgroundIsFar", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1.0, + "min": 0.0, + "max": 1.0 + }, + { + "name": "SampleCount", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 48.0, + "min": 4.0, + "max": 256.0 + }, + { + "name": "KernelRotation", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.0, + "min": -6.2832, + "max": 6.2832 + }, + { + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY" + } + ] + } + } + ] +} diff --git a/Plugins/nosFilters/Config/BokehShape.nosdef b/Plugins/nosFilters/Config/BokehShape.nosdef new file mode 100644 index 00000000..3a466b9a --- /dev/null +++ b/Plugins/nosFilters/Config/BokehShape.nosdef @@ -0,0 +1,93 @@ +{ + "nodes": [ + { + "class_name": "BokehShape", + "menu_info": { + "category": "Filters", + "display_name": "Bokeh Shape" + }, + "node": { + "class_name": "BokehShape", + "name": "Bokeh Shape", + "description": "Procedural bokeh kernel generator. Produces a unit-disc grayscale mask shaped like a regular polygon aperture (blade count, roundness, rotation), with soft edge and optional rim brightening. Feed the Output into a Bokeh DoF node's BokehShape pin.", + "contents_type": "Job", + "contents": { + "type": "nos.sys.vulkan.GPUNode", + "options": { + "shader": "Shaders/BokehShape.frag" + } + }, + "pins": [ + { + "name": "BladeCount", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 6.0, + "min": 0.0, + "max": 16.0 + }, + { + "name": "Roundness", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.3, + "min": 0.0, + "max": 1.0 + }, + { + "name": "Rotation", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.0, + "min": -6.2832, + "max": 6.2832 + }, + { + "name": "EdgeSoftness", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.04, + "min": 0.0, + "max": 0.5 + }, + { + "name": "RimBoost", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.0, + "min": 0.0, + "max": 4.0 + }, + { + "name": "RimWidth", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 0.08, + "min": 0.005, + "max": 0.5 + }, + { + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "data": { + "resolution": "CUSTOM", + "width": 128, + "height": 128, + "format": "R16_UNORM", + "usage": "TRANSFER_SRC TRANSFER_DST SAMPLED STORAGE RENDER_TARGET", + "filtering": "LINEAR" + } + } + ] + } + } + ] +} diff --git a/Plugins/nosFilters/Filters.noscfg b/Plugins/nosFilters/Filters.noscfg index c9ebb2ce..3660bb3f 100644 --- a/Plugins/nosFilters/Filters.noscfg +++ b/Plugins/nosFilters/Filters.noscfg @@ -29,6 +29,8 @@ "Config/DirectionalBlur.nosdef", "Config/DirectionalDof.nosdef", "Config/DepthOfField.nosdef", + "Config/BokehDof.nosdef", + "Config/BokehShape.nosdef", "Config/KawaseLightStreak.nosdef", "Config/Kuwahara.nosdef", "Config/PremultiplyAlpha.nosdef", diff --git a/Plugins/nosFilters/Shaders/BokehDof.frag b/Plugins/nosFilters/Shaders/BokehDof.frag new file mode 100644 index 00000000..b365ddf2 --- /dev/null +++ b/Plugins/nosFilters/Shaders/BokehDof.frag @@ -0,0 +1,105 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. +// Single-pass 2D bokeh depth-of-field with a kernel-texture shaping the bokeh. +// +// Computes a per-pixel circle of confusion (CoC) from a linear view-space Z +// input, then gathers samples on a Vogel (golden-angle) disc within that CoC. +// Each sample's contribution is weighted by BokehShape sampled at the same +// unit-disc position, so the bokeh takes on the shape painted into BokehShape +// (regular polygon, ring, custom artwork, etc.). + +#version 450 + +#define MASK_THRESHOLD 0.001 +#define GOLDEN_ANGLE 2.39996322972865332 + +layout(binding = 0) uniform sampler2D Input; +layout(binding = 1) uniform sampler2D Depth; +layout(binding = 2) uniform sampler2D BokehShape; +layout(binding = 3) uniform BokehDofParams +{ + // Focus distance in the same units as the Depth input (linear view-space Z). + float FocusDistance; + // Distance from focus where CoC reaches MaxRadius. + float FocusRange; + // Maximum CoC radius in pixels. + float MaxRadius; + // Skip the gather when CoC <= MinRadius (keeps focused regions crisp & cheap). + float MinRadius; + // 0 = treat zero depth as "near focus" (stays sharp); 1 = treat as far plane. + float BackgroundIsFar; + // Total Vogel-disc sample count. ~32 = soft, ~64 = clean, ~128 = no banding. + float SampleCount; + // Rotate the kernel lookup (radians). Useful for animated highlights. + float KernelRotation; +} +Params; + +layout(location = 0) out vec4 rt; +layout(location = 0) in vec2 uv; + +float CocFromDepth(float Z) +{ + if (Z <= 0.0) + Z = mix(Params.FocusDistance, Params.FocusDistance + Params.FocusRange * 4.0, Params.BackgroundIsFar); + + float D = abs(Z - Params.FocusDistance); + float Coc = D / max(Params.FocusRange, 1e-4); + return clamp(Coc * Params.MaxRadius, 0.0, Params.MaxRadius); +} + +void main() +{ + vec2 TextureSize = textureSize(Input, 0); + vec2 TexelSize = 1.0 / TextureSize; + + vec4 CenterColor = texture(Input, uv); + float CenterZ = texture(Depth, uv).r; + float CenterCoC = CocFromDepth(CenterZ); + + if (CenterCoC <= Params.MinRadius || Params.MaxRadius < MASK_THRESHOLD) + { + rt = CenterColor; + return; + } + + int N = int(max(1.0, Params.SampleCount)); + float CosR = cos(Params.KernelRotation); + float SinR = sin(Params.KernelRotation); + + // Vogel disc: golden-angle spiral with sqrt radius for uniform area density. + // Sample 0 is the center; included implicitly via CenterColor initialization. + vec4 Accum = CenterColor; + float Weight = texture(BokehShape, vec2(0.5)).r; + Accum *= Weight; + + for (int i = 1; i < N; ++i) + { + float Frac = float(i) / float(N); + float R = sqrt(Frac); // unit-disc radius + float Th = float(i) * GOLDEN_ANGLE; + vec2 Unit = vec2(cos(Th) * R, sin(Th) * R); // unit disc position + + // Rotated lookup into the bokeh kernel. + vec2 ShapeUv = vec2(Unit.x * CosR - Unit.y * SinR, + Unit.x * SinR + Unit.y * CosR) * 0.5 + 0.5; + float WShape = texture(BokehShape, ShapeUv).r; + if (WShape <= MASK_THRESHOLD) + continue; + + vec2 Ofs = Unit * CenterCoC * TexelSize; + vec4 Sample = texture(Input, uv + Ofs); + float ZSamp = texture(Depth, uv + Ofs).r; + float CocSmp = CocFromDepth(ZSamp); + + // Per-sample CoC rejection prevents in-focus pixels bleeding outward. + // A sample contributes only if its own CoC is at least its distance from center. + float Dist = R * CenterCoC; + float WCoc = Dist <= CocSmp ? 1.0 : 0.0; + + float W = WShape * WCoc; + Accum += Sample * W; + Weight += W; + } + + rt = Accum / max(Weight, 1e-4); +} diff --git a/Plugins/nosFilters/Shaders/BokehShape.frag b/Plugins/nosFilters/Shaders/BokehShape.frag new file mode 100644 index 00000000..cb963629 --- /dev/null +++ b/Plugins/nosFilters/Shaders/BokehShape.frag @@ -0,0 +1,77 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. +// Procedural bokeh kernel generator. +// +// Produces a grayscale unit-disc mask shaped like a regular polygon aperture +// (number of blades configurable) with optional roundness, rotation, soft edge +// and brightened rim. Intended as input to a kernel-weighted DoF gather. +// +// Convention: image is treated as the [-1, 1] unit square; pixels outside the +// kernel shape return 0; pixels inside return ~1, with a smooth edge falloff +// over EdgeSoftness. The mask is normalized so that center stays at 1. + +#version 450 + +#define PI 3.14159265358979323846 + +layout(location = 0) out vec4 rt; +layout(location = 0) in vec2 uv; + +layout(binding = 1) uniform BokehShapeParams +{ + // Aperture blade count. 0 or 1 = perfect circle. + float BladeCount; + // 0 = sharp polygon, 1 = perfect circle. Interpolates polygon edge toward disc. + float Roundness; + // Rotation of the polygon (radians). + float Rotation; + // Soft falloff width at the edge, in [0, 1] of unit-disc radius. + float EdgeSoftness; + // Extra brightness boost near the rim, [0, 1]. Mimics cat's-eye / specular bokeh. + float RimBoost; + // Width of the rim brightening band, in [0, 1] of radius. + float RimWidth; +} +Params; + +void main() +{ + // Map uv [0,1] to centered coords [-1,1] + vec2 Pos = uv * 2.0 - 1.0; + float R = length(Pos); + + if (R > 1.0) + { + rt = vec4(0.0); + return; + } + + float Blades = max(Params.BladeCount, 1.0); + + // Polygon edge radius along this angular direction. + // sectorAngle = 2*pi / N; angle from sector center is a; edge distance = cos(pi/N) / cos(a). + float PolygonR = 1.0; + if (Blades >= 3.0) + { + float Theta = atan(Pos.y, Pos.x) - Params.Rotation; + float SectorAngle = 2.0 * PI / Blades; + float HalfSector = SectorAngle * 0.5; + // Angle measured from the nearest sector centerline, in [-HalfSector, +HalfSector]. + float A = mod(Theta + HalfSector, SectorAngle) - HalfSector; + PolygonR = cos(HalfSector) / max(cos(A), 1e-4); + } + + // Roundness mixes polygon edge toward the circumscribed circle (radius 1). + float EdgeR = mix(PolygonR, 1.0, clamp(Params.Roundness, 0.0, 1.0)); + + // Soft edge: 1 inside, 0 past the edge, smooth across EdgeSoftness. + float Soft = max(Params.EdgeSoftness, 1e-4); + float Mask = 1.0 - smoothstep(EdgeR - Soft, EdgeR, R); + + // Rim brightening: a soft band just inside the edge. + float RimW = max(Params.RimWidth, 1e-4); + float RimPos = (R - (EdgeR - RimW)) / RimW; // 0 at inner edge of rim, 1 at outer + float Rim = clamp(1.0 - abs(RimPos * 2.0 - 1.0), 0.0, 1.0); + Mask += Rim * Params.RimBoost * Mask; + + rt = vec4(Mask, Mask, Mask, 1.0); +} From dc03afcb307b62db45d37c0cf4a345927ca56c32 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 20 May 2026 13:16:35 +0300 Subject: [PATCH 28/30] Add Text Render node to nosUtilities SDF-based text rendering node built on FreeType. Supports multi-line word wrapping, outline, drop shadow, a text-box background, global opacity and a position offset. Bundles Roboto Mono (OFL) as the default font and vendors FreeType as a submodule. --- .gitmodules | 3 + Plugins/nosUtilities/.nospub | 1 + Plugins/nosUtilities/CMakeLists.txt | 16 +- Plugins/nosUtilities/Config/TextRender.fbs | 15 + Plugins/nosUtilities/Config/TextRender.nosdef | 275 +++++++ Plugins/nosUtilities/External/freetype | 1 + .../nosUtilities/Fonts/LICENSE-RobotoMono.txt | 93 +++ .../nosUtilities/Fonts/RobotoMono-Regular.ttf | Bin 0 -> 183700 bytes Plugins/nosUtilities/Shaders/TextBox.frag | 19 + Plugins/nosUtilities/Shaders/TextBox.vert | 29 + Plugins/nosUtilities/Shaders/TextGlyph.frag | 42 ++ Plugins/nosUtilities/Shaders/TextGlyph.vert | 34 + Plugins/nosUtilities/Source/TextRender.cpp | 699 ++++++++++++++++++ Plugins/nosUtilities/Source/UtilitiesMain.cpp | 3 + Plugins/nosUtilities/Utilities.noscfg | 6 +- 15 files changed, 1232 insertions(+), 4 deletions(-) create mode 100644 Plugins/nosUtilities/Config/TextRender.fbs create mode 100644 Plugins/nosUtilities/Config/TextRender.nosdef create mode 160000 Plugins/nosUtilities/External/freetype create mode 100644 Plugins/nosUtilities/Fonts/LICENSE-RobotoMono.txt create mode 100644 Plugins/nosUtilities/Fonts/RobotoMono-Regular.ttf create mode 100644 Plugins/nosUtilities/Shaders/TextBox.frag create mode 100644 Plugins/nosUtilities/Shaders/TextBox.vert create mode 100644 Plugins/nosUtilities/Shaders/TextGlyph.frag create mode 100644 Plugins/nosUtilities/Shaders/TextGlyph.vert create mode 100644 Plugins/nosUtilities/Source/TextRender.cpp diff --git a/.gitmodules b/.gitmodules index 6e456528..0479e3d8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ [submodule "Plugins/nosWebRTC/Source/ssl-cert"] path = Plugins/nosWebRTC/Source/ssl-cert url = https://github.com/mediaz/ssl-cert.git +[submodule "Plugins/nosUtilities/External/freetype"] + path = Plugins/nosUtilities/External/freetype + url = https://github.com/freetype/freetype.git diff --git a/Plugins/nosUtilities/.nospub b/Plugins/nosUtilities/.nospub index 609d521e..9883f1c9 100644 --- a/Plugins/nosUtilities/.nospub +++ b/Plugins/nosUtilities/.nospub @@ -4,6 +4,7 @@ "*.noscfg", "Include/**", "Assets/**", + "Fonts/**", "Shaders/*.{hlsl,glsl,frag,vert,spv,comp}", "Binaries/*.{dll,dylib,so}" ], diff --git a/Plugins/nosUtilities/CMakeLists.txt b/Plugins/nosUtilities/CMakeLists.txt index 62db52c7..54c5910b 100644 --- a/Plugins/nosUtilities/CMakeLists.txt +++ b/Plugins/nosUtilities/CMakeLists.txt @@ -12,5 +12,17 @@ foreach(module_name_version ${MODULE_DEPENDENCIES}) list(APPEND MODULE_DEPENDENCIES_TARGETS ${DEP_${dep_idx}}) endforeach() -list(APPEND DEPENDENCIES stb nosUtilities_generated ${NOS_PLUGIN_SDK_TARGET} ${MODULE_DEPENDENCIES_TARGETS}) -nos_add_plugin("nosUtilities" "${DEPENDENCIES}" "") \ No newline at end of file +# FreeType is vendored as a submodule and built with no external dependencies +# so the text rendering node has a self-contained font rasterizer. +if (NOT TARGET freetype) + set(FT_DISABLE_ZLIB ON CACHE BOOL "" FORCE) + set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE) + set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE) + set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) + set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) + add_subdirectory(External/freetype EXCLUDE_FROM_ALL) + nos_group_targets("freetype" "External") +endif() + +list(APPEND DEPENDENCIES stb freetype nosUtilities_generated ${NOS_PLUGIN_SDK_TARGET} ${MODULE_DEPENDENCIES_TARGETS}) +nos_add_plugin("nosUtilities" "${DEPENDENCIES}" "") diff --git a/Plugins/nosUtilities/Config/TextRender.fbs b/Plugins/nosUtilities/Config/TextRender.fbs new file mode 100644 index 00000000..8bfe4e6f --- /dev/null +++ b/Plugins/nosUtilities/Config/TextRender.fbs @@ -0,0 +1,15 @@ +namespace nos.utilities; + +enum TextHAlign : uint +{ + LEFT = 0, + CENTER = 1, + RIGHT = 2, +} + +enum TextVAlign : uint +{ + TOP = 0, + MIDDLE = 1, + BOTTOM = 2, +} diff --git a/Plugins/nosUtilities/Config/TextRender.nosdef b/Plugins/nosUtilities/Config/TextRender.nosdef new file mode 100644 index 00000000..5ad61bde --- /dev/null +++ b/Plugins/nosUtilities/Config/TextRender.nosdef @@ -0,0 +1,275 @@ +{ + "nodes": [ + { + "class_name": "TextRender", + "menu_info": { + "category": "Utilities", + "display_name": "Text Render", + "name_aliases": [ "text", "font", "label", "caption", "subtitle", "string to texture" ] + }, + "node": { + "class_name": "TextRender", + "contents_type": "Job", + "description": "Renders text into a texture using a signed distance field font atlas.\nSupports multi-line text, word wrapping, outline, drop shadow and a text-box background.", + "pins": [ + { + "name": "Text", + "type_name": "string", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "Text", + "description": "Text to render. Newlines and word wrapping are honored." + }, + { + "name": "FontSize", + "display_name": "Font Size", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Font" } + ], + "data": 64.0, + "min": 1.0, + "description": "Glyph height in pixels." + }, + { + "name": "Color", + "type_name": "nos.fb.vec4", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + "type": "COLOR_PICKER" + }, + "data": { + "x": 1, + "y": 1, + "z": 1, + "w": 1 + }, + "description": "Fill color of the text." + }, + { + "name": "Opacity", + "type_name": "float", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1.0, + "min": 0.0, + "max": 1.0, + "description": "Global opacity multiplier applied to text, outline, shadow and background." + }, + { + "name": "StrokeColor", + "display_name": "Stroke Color", + "type_name": "nos.fb.vec4", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Outline" } + ], + "visualizer": { + "type": "COLOR_PICKER" + }, + "data": { + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "description": "Outline color. Only drawn when Stroke Width is greater than 0." + }, + { + "name": "StrokeWidth", + "display_name": "Stroke Width", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Outline" } + ], + "data": 0.0, + "min": 0.0, + "description": "Outline thickness in pixels. 0 disables the outline." + }, + { + "name": "ShadowColor", + "display_name": "Shadow Color", + "type_name": "nos.fb.vec4", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Shadow" } + ], + "visualizer": { + "type": "COLOR_PICKER" + }, + "data": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + }, + "description": "Drop shadow color. Alpha 0 disables the shadow." + }, + { + "name": "ShadowOffset", + "display_name": "Shadow Offset", + "type_name": "nos.fb.vec2", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Shadow" } + ], + "data": { + "x": 3, + "y": 3 + }, + "description": "Drop shadow offset in pixels (x right, y down)." + }, + { + "name": "ShadowSoftness", + "display_name": "Shadow Softness", + "type_name": "float", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Shadow" } + ], + "data": 3.0, + "min": 0.0, + "description": "Drop shadow edge blur in pixels." + }, + { + "name": "BackgroundColor", + "display_name": "Background Color", + "type_name": "nos.fb.vec4", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Background" } + ], + "visualizer": { + "type": "COLOR_PICKER" + }, + "data": { + "x": 0, + "y": 0, + "z": 0, + "w": 0.6 + }, + "description": "Color of the box drawn behind the text block.\nAlpha 0 disables the box; the frame stays transparent." + }, + { + "name": "BackgroundPadding", + "display_name": "Background Padding", + "type_name": "nos.fb.vec2", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Background" } + ], + "data": { + "x": 20, + "y": 10 + }, + "description": "Padding in pixels between the text and the background box edges." + }, + { + "name": "HorizontalAlign", + "display_name": "Horizontal Align", + "type_name": "nos.utilities.TextHAlign", + "show_as": "PROPERTY", + "can_show_as": "INPUT_OUTPUT_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Layout" } + ], + "data": "CENTER", + "description": "Horizontal anchor of the text block within the output." + }, + { + "name": "VerticalAlign", + "display_name": "Vertical Align", + "type_name": "nos.utilities.TextVAlign", + "show_as": "PROPERTY", + "can_show_as": "INPUT_OUTPUT_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Layout" } + ], + "data": "BOTTOM", + "description": "Vertical anchor of the text block within the output." + }, + { + "name": "Position", + "type_name": "nos.fb.vec2", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Layout" } + ], + "data": { + "x": 0, + "y": -50 + }, + "description": "Extra offset in pixels applied on top of the alignment anchor." + }, + { + "name": "Resolution", + "type_name": "nos.fb.vec2u", + "show_as": "PROPERTY", + "can_show_as": "INPUT_OUTPUT_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Layout" } + ], + "data": { + "x": 1920, + "y": 1080 + }, + "description": "Output texture resolution.", + "visualizer": { + "type": "NAMED_VALUE", + "name": "nos.mediaio.ResolutionVisualizer" + } + }, + { + "name": "WrapWidth", + "display_name": "Wrap Width", + "type_name": "uint", + "show_as": "PROPERTY", + "can_show_as": "INPUT_OUTPUT_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Layout" } + ], + "data": 0, + "description": "Word-wrap width in characters.\n0 wraps to the output texture width." + }, + { + "name": "Font", + "type_name": "string", + "show_as": "PROPERTY", + "can_show_as": "INPUT_OUTPUT_PROPERTY", + "meta_data_map": [ + { "key": "Category", "value": "Font" } + ], + "data": "", + "description": "Path to a .ttf/.otf font file.\nLeave empty to use the bundled Roboto Mono font.", + "visualizer": { + "type": "FILE_PICKER", + "file_extensions": [ "ttf", "otf" ], + "file_picker_type": "OPEN" + } + }, + { + "name": "Output", + "type_name": "nos.sys.vulkan.Texture", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "data": { + "unscaled": true + } + } + ] + } + } + ] +} diff --git a/Plugins/nosUtilities/External/freetype b/Plugins/nosUtilities/External/freetype new file mode 160000 index 00000000..7e0e56f8 --- /dev/null +++ b/Plugins/nosUtilities/External/freetype @@ -0,0 +1 @@ +Subproject commit 7e0e56f84fd53cf38378d33c8fc8f92d12ab9ac6 diff --git a/Plugins/nosUtilities/Fonts/LICENSE-RobotoMono.txt b/Plugins/nosUtilities/Fonts/LICENSE-RobotoMono.txt new file mode 100644 index 00000000..8e7df338 --- /dev/null +++ b/Plugins/nosUtilities/Fonts/LICENSE-RobotoMono.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Plugins/nosUtilities/Fonts/RobotoMono-Regular.ttf b/Plugins/nosUtilities/Fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f21d1d716bce3cc756bc618d32be71ce6f733f81 GIT binary patch literal 183700 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%0ArpqSAxi_|zS#be zxir{~0P4o*kYh=+4!7>v13P61yN26{xA14FCm>Nd#9Mv>x9!~vPYUr(v~`4h>q(1Ou0hLt`7_g8p5p%E)U=*O1TgJKN2G1QI6v@Xgi*}8VKgTlR z8b%z&JRSmH0H7ck#WdWv!ng=ElX{{fq!l27{knjk2$mwH-+`#$7s!i}SJZD1yA*<( zO={IFw`-AIqpMJ>vib2;hUH|lBtxpPFLc3PqS}O)MQj^xoexQH4Z_8;+T^suwKJ~u z#H{(KZqB(3eRli`VPj6teXa9{DHzfLhKf-bZW^|f9~=0+>6ucz9E66buqfgE8CRQK zBg>T;Dn)U!1Rgg>rK#3y?2Fv401P=1`+lCg;NHBPM&V*Xi6i6O!jgB_#VmhiTEm?g z>5Y=b!iLyfA;>Czc*w(1?g_6B_FK? zb`O|%Otky`_tBP+zP-P9?hU;Rb+X8v#OEO?fgOe*9s@7v1pMv^h@@tv0LKXaQ}ZNV z<)l``C&ZzJOP4N+;&Q6;In7&*7Q==uTQ-rOqn{G!=%vGY{QO1mu` zZzn3!vrbl=+Oc{ETC@FR`JI_LlPAF70+tKnY-}N&qkc_5YK(ezqac9thSeR#Hp=2O zZnc<=uzlK^U)dq!@k|cWriQru$y`pf*_x20)nxE(7EXLZ<&Ac{IxFkW@{_qOa`3}< z7%Z~s$)_ft$w?_D0BDN*K)t==^#X~|PMp6L;Uu{gsfml^VsZ%@WP|)!j_st?(k=xg zDp*ut^in|sC;s#hIS<`Fg!aJq1x}JRm&p|L1gsQx65j;x%a2Pk;;Z3D0+I}}o8gla zR@#ry)2PKT))xV?5P#|f*^5@6K&|@WM~E6K>0vLVK^)pUd@3kAFJk|q`Dn8!IM_Ud z+=vQbhoXziQ4uO$PF_U$%gG1H%gff;MUDKhg z_=&Bg(ElR(s1HOlGqZFCT5x3KXoF+d(UBvAj?2_|TTmP=l^6-`nNa)QLHm_5azUg7 zjR#O)#NR&I6Jc4A?%GVWn=2O`hzow5%0A>c(48Q8CXw`oy^)qhO{tYXvd) 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%0AT34)cs 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 +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include FT_FREETYPE_H +#include FT_MODULE_H + +namespace nos::utilities +{ +namespace fs = std::filesystem; + +NOS_REGISTER_NAME(TextRender) +NOS_REGISTER_NAME(TextGlyph_Pass) +NOS_REGISTER_NAME(TextGlyph_Frag) +NOS_REGISTER_NAME(TextGlyph_Vert) +NOS_REGISTER_NAME(TextBox_Pass) +NOS_REGISTER_NAME(TextBox_Frag) +NOS_REGISTER_NAME(TextBox_Vert) + +NOS_REGISTER_NAME(Text) +NOS_REGISTER_NAME(FontSize) +NOS_REGISTER_NAME(Color) +NOS_REGISTER_NAME(Opacity) +NOS_REGISTER_NAME(StrokeColor) +NOS_REGISTER_NAME(StrokeWidth) +NOS_REGISTER_NAME(ShadowColor) +NOS_REGISTER_NAME(ShadowOffset) +NOS_REGISTER_NAME(ShadowSoftness) +NOS_REGISTER_NAME(BackgroundColor) +NOS_REGISTER_NAME(BackgroundPadding) +NOS_REGISTER_NAME(HorizontalAlign) +NOS_REGISTER_NAME(VerticalAlign) +NOS_REGISTER_NAME(Position) +NOS_REGISTER_NAME(Resolution) +NOS_REGISTER_NAME(WrapWidth) +NOS_REGISTER_NAME(Font) +NOS_REGISTER_NAME(Output) + +NOS_REGISTER_NAME(Offset) +NOS_REGISTER_NAME(Size) +NOS_REGISTER_NAME(AtlasRect) +NOS_REGISTER_NAME(FillColor) +NOS_REGISTER_NAME(Softness) +NOS_REGISTER_NAME(PxRange) +NOS_REGISTER_NAME(Atlas) +NOS_REGISTER_NAME(BoxColor) + +// ASCII printable range packed into the SDF atlas. +constexpr uint32_t FIRST_CHAR = 32; +constexpr uint32_t LAST_CHAR = 126; +constexpr uint32_t GLYPH_COUNT = LAST_CHAR - FIRST_CHAR + 1; +// Reference size the atlas is rasterized at; the SDF is scaled by the shader. +constexpr float REF_PIXEL_SIZE = 72.0f; +// SDF spread in reference pixels: how far signed-distance data extends from the +// glyph edge. Caps the usable outline thickness and shadow softness. +constexpr int SDF_SPREAD = 16; +constexpr int ATLAS_WIDTH = 1024; +constexpr int ATLAS_PADDING = 2; +// Upper bound on draw calls per execution to keep pathological inputs cheap. +constexpr size_t MAX_DRAWN_GLYPHS = 8192; + +struct Glyph +{ + int AtlasX = 0, AtlasY = 0; // top-left in atlas pixels + int Width = 0, Height = 0; // bitmap size in reference pixels + float BearingLeft = 0; // pen-origin to bitmap left, reference pixels + float BearingTop = 0; // baseline to bitmap top, reference pixels + float Advance = 0; // horizontal advance, reference pixels + bool HasBitmap = false; +}; + +struct TextRenderNode : NodeContext +{ + FT_Library Library = nullptr; + nosResourceShareInfo AtlasTex{}; + + std::array Glyphs{}; + int AtlasW = 0, AtlasH = 0; + float RefLineHeight = REF_PIXEL_SIZE * 1.2f; + float RefAscender = REF_PIXEL_SIZE; + + // Path the current atlas was built from; empty means "bundled font". + std::optional BuiltFontPath; + bool AtlasValid = false; + + TextRenderNode(nosFbNodePtr node) : NodeContext(node) + { + if (FT_Init_FreeType(&Library)) + { + Library = nullptr; + nosEngine.LogE("TextRender: failed to initialize FreeType"); + return; + } + FT_Int spread = SDF_SPREAD; + FT_Property_Set(Library, "sdf", "spread", &spread); + } + + ~TextRenderNode() override + { + DestroyAtlas(); + if (Library) + FT_Done_FreeType(Library); + } + + void DestroyAtlas() + { + if (AtlasTex.Memory.Handle) + nosVulkan->DestroyResource(&AtlasTex); + AtlasTex = {}; + AtlasValid = false; + } + + std::string ResolveFontPath(const char* fontPin) const + { + if (fontPin && fontPin[0] != '\0') + return fontPin; + fs::path root = nosEngine.Module->RootFolderPath; + return (root / "Fonts" / "RobotoMono-Regular.ttf").generic_string(); + } + + // Rasterizes the printable ASCII range into a single-channel SDF atlas + // and uploads it as a texture. Returns false on failure. + bool BuildAtlas(const std::string& fontPath) + { + if (!Library) + return false; + + FT_Face face = nullptr; + if (FT_New_Face(Library, fontPath.c_str(), 0, &face)) + { + nosEngine.LogE("TextRender: could not open font '%s'", fontPath.c_str()); + return false; + } + FT_Set_Pixel_Sizes(face, 0, static_cast(REF_PIXEL_SIZE)); + + RefLineHeight = (face->size->metrics.height >> 6) > 0 ? float(face->size->metrics.height >> 6) + : REF_PIXEL_SIZE * 1.2f; + RefAscender = (face->size->metrics.ascender >> 6) > 0 ? float(face->size->metrics.ascender >> 6) + : REF_PIXEL_SIZE; + + struct Raster + { + std::vector Pixels; + int W = 0, H = 0; + }; + std::array rasters{}; + std::array glyphs{}; + + for (uint32_t i = 0; i < GLYPH_COUNT; ++i) + { + Glyph& g = glyphs[i]; + if (FT_Load_Char(face, FIRST_CHAR + i, FT_LOAD_DEFAULT)) + continue; + FT_GlyphSlot slot = face->glyph; + g.Advance = float(slot->advance.x >> 6); + + if (FT_Render_Glyph(slot, FT_RENDER_MODE_SDF)) + continue; // whitespace / empty outline: advance is still valid + + const FT_Bitmap& bm = slot->bitmap; + if (bm.width == 0 || bm.rows == 0) + continue; + + g.Width = int(bm.width); + g.Height = int(bm.rows); + g.BearingLeft = float(slot->bitmap_left); + g.BearingTop = float(slot->bitmap_top); + g.HasBitmap = true; + + Raster& r = rasters[i]; + r.W = int(bm.width); + r.H = int(bm.rows); + r.Pixels.resize(size_t(r.W) * r.H); + const int pitch = bm.pitch; + for (int row = 0; row < r.H; ++row) + { + const uint8_t* src = bm.buffer + size_t(row) * (pitch < 0 ? -pitch : pitch); + std::memcpy(r.Pixels.data() + size_t(row) * r.W, src, r.W); + } + } + FT_Done_Face(face); + + // Shelf-pack the glyph bitmaps into a fixed-width atlas. + int x = ATLAS_PADDING, y = ATLAS_PADDING, shelfH = 0; + for (uint32_t i = 0; i < GLYPH_COUNT; ++i) + { + Glyph& g = glyphs[i]; + if (!g.HasBitmap) + continue; + if (x + g.Width + ATLAS_PADDING > ATLAS_WIDTH) + { + x = ATLAS_PADDING; + y += shelfH + ATLAS_PADDING; + shelfH = 0; + } + g.AtlasX = x; + g.AtlasY = y; + x += g.Width + ATLAS_PADDING; + shelfH = std::max(shelfH, g.Height); + } + const int atlasW = ATLAS_WIDTH; + const int atlasH = y + shelfH + ATLAS_PADDING; + + std::vector pixels(size_t(atlasW) * atlasH, 0); + for (uint32_t i = 0; i < GLYPH_COUNT; ++i) + { + const Glyph& g = glyphs[i]; + const Raster& r = rasters[i]; + if (!g.HasBitmap) + continue; + for (int row = 0; row < r.H; ++row) + std::memcpy(pixels.data() + size_t(g.AtlasY + row) * atlasW + g.AtlasX, + r.Pixels.data() + size_t(row) * r.W, + r.W); + } + + DestroyAtlas(); + + nosResourceShareInfo atlas{}; + atlas.Info.Type = NOS_RESOURCE_TYPE_TEXTURE; + atlas.Info.Texture = {.Width = uint32_t(atlasW), + .Height = uint32_t(atlasH), + .Format = NOS_FORMAT_R8_UNORM}; + auto cmd = vkss::BeginCmd(NOS_NAME("TextRenderAtlasUpload"), NodeId); + nosResult res = nosVulkan->ImageLoad(cmd, + pixels.data(), + nosVec2u{uint32_t(atlasW), uint32_t(atlasH)}, + NOS_FORMAT_R8_UNORM, + &atlas, + "TextRenderAtlas"); + vkss::EndCmd(cmd, NOS_TRUE, nullptr); + if (res != NOS_RESULT_SUCCESS) + { + nosEngine.LogE("TextRender: failed to upload font atlas"); + return false; + } + + atlas.Info.Texture.Filter = NOS_TEXTURE_FILTER_LINEAR; + AtlasTex = atlas; + AtlasW = atlasW; + AtlasH = atlasH; + Glyphs = glyphs; + AtlasValid = true; + return true; + } + + void EnsureAtlas(const char* fontPin) + { + std::string path = ResolveFontPath(fontPin); + if (AtlasValid && BuiltFontPath && *BuiltFontPath == path) + return; + BuiltFontPath = path; + if (BuildAtlas(path)) + ClearNodeStatusMessages(); + else + SetNodeStatusMessage("Could not load font.", fb::NodeStatusMessageType::FAILURE); + } + + const Glyph* GlyphFor(char c) const + { + auto u = uint32_t(uint8_t(c)); + if (u < FIRST_CHAR || u > LAST_CHAR) + return nullptr; + return &Glyphs[u - FIRST_CHAR]; + } + + // One laid-out glyph: index into Glyphs plus its pen origin on the line. + struct Placed + { + uint32_t GlyphIndex; + float PenX; + int Line; + }; + + // Greedy word-wrap layout in output-pixel space. Honors '\n' and breaks + // words longer than maxWidth character by character. + void LayoutText(const char* text, + float scale, + float maxWidth, + std::vector& out, + std::vector& lineWidths) const + { + const float spaceAdvance = [&] { + const Glyph* sp = GlyphFor(' '); + return sp ? sp->Advance * scale : REF_PIXEL_SIZE * 0.3f * scale; + }(); + + int line = 0; + float penX = 0.0f; + lineWidths.push_back(0.0f); + + auto newLine = [&] { + lineWidths[line] = penX; + ++line; + penX = 0.0f; + lineWidths.push_back(0.0f); + }; + auto placeChar = [&](char c) { + const Glyph* g = GlyphFor(c); + if (!g) + return; + auto idx = uint32_t(uint8_t(c)) - FIRST_CHAR; + if (g->HasBitmap && out.size() < MAX_DRAWN_GLYPHS) + out.push_back({idx, penX, line}); + penX += g->Advance * scale; + }; + + std::string word; + auto wordWidth = [&](const std::string& w) { + float width = 0.0f; + for (char c : w) + if (const Glyph* g = GlyphFor(c)) + width += g->Advance * scale; + return width; + }; + auto flushWord = [&] { + if (word.empty()) + return; + float ww = wordWidth(word); + if (ww > maxWidth) + { + // Word does not fit on any line: hard-break per character. + for (char c : word) + { + const Glyph* g = GlyphFor(c); + float adv = g ? g->Advance * scale : 0.0f; + if (penX > 0.0f && penX + adv > maxWidth) + newLine(); + placeChar(c); + } + } + else + { + if (penX > 0.0f && penX + ww > maxWidth) + newLine(); + for (char c : word) + placeChar(c); + } + word.clear(); + }; + + for (const char* p = text; *p; ++p) + { + char c = *p; + if (c == '\n') + { + flushWord(); + newLine(); + } + else if (c == ' ' || c == '\t') + { + flushWord(); + float adv = (c == '\t') ? spaceAdvance * 4.0f : spaceAdvance; + if (penX > 0.0f) + penX += adv; + } + else + { + word.push_back(c); + } + } + flushWord(); + lineWidths[line] = penX; + } + + // Draws a flat-colored rectangle (the text-box background). + void DrawBox(nosCmd cmd, + const nosResourceShareInfo& tex, + float outW, + float outH, + float x, + float y, + float w, + float h, + nosVec4 boxColor) + { + nosVec2 offset{x / outW, y / outH}; + nosVec2 size{w / outW, h / outH}; + std::array bindings = {vkss::ShaderBinding(NSN_Offset, offset), + vkss::ShaderBinding(NSN_Size, size), + vkss::ShaderBinding(NSN_BoxColor, boxColor)}; + nosVertexData vertexData{ + .DepthFunc = NOS_DEPTH_FUNCTION_ALWAYS, + .DepthWrite = NOS_FALSE, + .DepthTest = NOS_FALSE, + }; + nosRunPassParams pass{.Key = NSN_TextBox_Pass, + .Bindings = bindings.data(), + .BindingCount = uint32_t(bindings.size()), + .Output = tex, + .Vertices = vertexData, + .Wireframe = NOS_FALSE, + .Benchmark = NOS_FALSE, + .DoNotClear = NOS_TRUE}; + nosVulkan->RunPass(cmd, &pass); + } + + // Draws one glyph quad. Used for both the shadow and the fill/stroke pass: + // the shadow passes the shadow color as the fill with a softened edge. + void DrawGlyph(nosCmd cmd, + const nosResourceShareInfo& tex, + float outW, + float outH, + float glyphLeft, + float glyphTop, + float glyphW, + float glyphH, + nosVec4 atlasRect, + nosVec4 fillColor, + nosVec4 strokeColor, + float strokeWidth, + float softness, + float pxRange) + { + nosVec2 offset{glyphLeft / outW, glyphTop / outH}; + nosVec2 size{glyphW / outW, glyphH / outH}; + std::array bindings = {vkss::ShaderBinding(NSN_Offset, offset), + vkss::ShaderBinding(NSN_Size, size), + vkss::ShaderBinding(NSN_AtlasRect, atlasRect), + vkss::ShaderBinding(NSN_FillColor, fillColor), + vkss::ShaderBinding(NSN_StrokeColor, strokeColor), + vkss::ShaderBinding(NSN_StrokeWidth, strokeWidth), + vkss::ShaderBinding(NSN_Softness, softness), + vkss::ShaderBinding(NSN_PxRange, pxRange), + vkss::ShaderBinding(NSN_Atlas, AtlasTex)}; + nosVertexData vertexData{ + .DepthFunc = NOS_DEPTH_FUNCTION_ALWAYS, + .DepthWrite = NOS_FALSE, + .DepthTest = NOS_FALSE, + }; + nosRunPassParams pass{.Key = NSN_TextGlyph_Pass, + .Bindings = bindings.data(), + .BindingCount = uint32_t(bindings.size()), + .Output = tex, + .Vertices = vertexData, + .Wireframe = NOS_FALSE, + .Benchmark = NOS_FALSE, + .DoNotClear = NOS_TRUE}; + nosVulkan->RunPass(cmd, &pass); + } + + nosResult ExecuteNode(nosNodeExecuteParams* rawParams) override + { + NodeExecuteParams args(rawParams); + + const char* fontPin = args.GetPinData(NSN_Font); + EnsureAtlas(fontPin); + + auto resolution = *reinterpret_cast(args[NSN_Resolution].Data->Data); + if (resolution.x == 0 || resolution.y == 0) + return NOS_RESULT_SUCCESS; + + // Resize the output texture to match the requested resolution. + auto tex = vkss::DeserializeTextureInfo(args[NSN_Output].Data->Data); + if (tex.Info.Texture.Width != resolution.x || tex.Info.Texture.Height != resolution.y) + { + auto resized = tex; + resized.Memory = {}; + resized.Info.Texture.Width = resolution.x; + resized.Info.Texture.Height = resolution.y; + auto texFb = vkss::ConvertTextureInfo(resized); + texFb.unscaled = true; + auto buf = nos::Buffer::From(texFb); + nosEngine.SetPinValue(args[NSN_Output].Id, {.Data = buf.Data(), .Size = buf.Size()}); + tex = vkss::DeserializeTextureInfo(args[NSN_Output].Data->Data); + } + if (tex.Memory.Handle == 0) + return NOS_RESULT_SUCCESS; + + const char* text = args.GetPinData(NSN_Text); + float fontSize = *reinterpret_cast(args[NSN_FontSize].Data->Data); + float opacity = std::clamp(*reinterpret_cast(args[NSN_Opacity].Data->Data), 0.0f, 1.0f); + auto textColor = *reinterpret_cast(args[NSN_Color].Data->Data); + auto strokeColor = *reinterpret_cast(args[NSN_StrokeColor].Data->Data); + float strokeWidthPin = *reinterpret_cast(args[NSN_StrokeWidth].Data->Data); + auto shadowColor = *reinterpret_cast(args[NSN_ShadowColor].Data->Data); + auto shadowOffset = *reinterpret_cast(args[NSN_ShadowOffset].Data->Data); + float shadowSoftnessPin = *reinterpret_cast(args[NSN_ShadowSoftness].Data->Data); + auto boxColor = *reinterpret_cast(args[NSN_BackgroundColor].Data->Data); + auto boxPadding = *reinterpret_cast(args[NSN_BackgroundPadding].Data->Data); + auto hAlign = *reinterpret_cast(args[NSN_HorizontalAlign].Data->Data); + auto vAlign = *reinterpret_cast(args[NSN_VerticalAlign].Data->Data); + auto position = *reinterpret_cast(args[NSN_Position].Data->Data); + auto wrapWidthChars = *reinterpret_cast(args[NSN_WrapWidth].Data->Data); + + // Global opacity folds into every color's alpha. + textColor.w *= opacity; + strokeColor.w *= opacity; + shadowColor.w *= opacity; + boxColor.w *= opacity; + + // The frame outside the text box stays transparent. + auto cmd = vkss::BeginCmd(NOS_NAME("TextRender"), NodeId); + nosVulkan->Clear(cmd, &tex, nosVec4{0.0f, 0.0f, 0.0f, 0.0f}); + + if (AtlasValid && text && text[0] != '\0' && fontSize > 0.0f) + { + const float outW = float(tex.Info.Texture.Width); + const float outH = float(tex.Info.Texture.Height); + const float scale = fontSize / REF_PIXEL_SIZE; + const float lineHeight = RefLineHeight * scale; + const float ascender = RefAscender * scale; + const float pxRange = 2.0f * float(SDF_SPREAD) * scale; + // The SDF only carries data within SDF_SPREAD reference pixels of the + // glyph edge, which bounds outline thickness and shadow softness. + const float effectLimit = float(SDF_SPREAD) * scale; + const float strokeWidth = std::clamp(strokeWidthPin, 0.0f, effectLimit); + const float shadowSoftness = std::clamp(shadowSoftnessPin, 0.0f, effectLimit); + + // WrapWidth is in characters; 0 falls back to the texture width. + // The character cell width is the font's space advance, which is + // exact for monospace fonts and approximate for proportional ones. + float wrapWidth = outW; + if (wrapWidthChars > 0) + { + const Glyph* space = GlyphFor(' '); + const float charWidth = (space ? space->Advance : REF_PIXEL_SIZE * 0.6f) * scale; + wrapWidth = float(wrapWidthChars) * charWidth; + } + + std::vector placed; + std::vector lineWidths; + LayoutText(text, scale, wrapWidth, placed, lineWidths); + + const uint32_t numLines = uint32_t(lineWidths.size()); + const float blockHeight = lineHeight * float(numLines); + + float vBase = 0.0f; + if (vAlign == 1) // MIDDLE + vBase = (outH - blockHeight) * 0.5f; + else if (vAlign == 2) // BOTTOM + vBase = outH - blockHeight; + const float vOffset = vBase + position.y; + + // Per-line horizontal anchor offset (alignment + position nudge). + std::vector hOffsets(numLines); + for (uint32_t i = 0; i < numLines; ++i) + { + float off = 0.0f; + if (hAlign == 1) // CENTER + off = (outW - lineWidths[i]) * 0.5f; + else if (hAlign == 2) // RIGHT + off = outW - lineWidths[i]; + hOffsets[i] = off + position.x; + } + + // Text-block bounds, used for the background box. + float blockLeft = outW, blockRight = 0.0f; + bool anyLine = false; + for (uint32_t i = 0; i < numLines; ++i) + { + if (lineWidths[i] <= 0.0f) + continue; + anyLine = true; + blockLeft = std::min(blockLeft, hOffsets[i]); + blockRight = std::max(blockRight, hOffsets[i] + lineWidths[i]); + } + + // Background box, behind everything. + if (anyLine && boxColor.w > 0.0f) + DrawBox(cmd, + tex, + outW, + outH, + blockLeft - boxPadding.x, + vOffset - boxPadding.y, + (blockRight - blockLeft) + 2.0f * boxPadding.x, + blockHeight + 2.0f * boxPadding.y, + boxColor); + + auto glyphRect = [&](const Placed& gp, float& left, float& top, float& w, float& h) { + const Glyph& g = Glyphs[gp.GlyphIndex]; + const float baseline = vOffset + ascender + float(gp.Line) * lineHeight; + left = hOffsets[gp.Line] + gp.PenX + g.BearingLeft * scale; + top = baseline - g.BearingTop * scale; + w = float(g.Width) * scale; + h = float(g.Height) * scale; + }; + auto atlasRectOf = [&](const Placed& gp) { + const Glyph& g = Glyphs[gp.GlyphIndex]; + return nosVec4{float(g.AtlasX) / float(AtlasW), + float(g.AtlasY) / float(AtlasH), + float(g.Width) / float(AtlasW), + float(g.Height) / float(AtlasH)}; + }; + + // Drop shadow: all shadows first so no glyph fill is tinted by a + // neighbouring glyph's shadow. + if (shadowColor.w > 0.0f) + { + nosVec4 noStroke{0.0f, 0.0f, 0.0f, 0.0f}; + for (const Placed& gp : placed) + { + float left, top, w, h; + glyphRect(gp, left, top, w, h); + DrawGlyph(cmd, + tex, + outW, + outH, + left + shadowOffset.x, + top + shadowOffset.y, + w, + h, + atlasRectOf(gp), + shadowColor, + noStroke, + 0.0f, + shadowSoftness, + pxRange); + } + } + + // Fill + outline. + for (const Placed& gp : placed) + { + float left, top, w, h; + glyphRect(gp, left, top, w, h); + DrawGlyph(cmd, + tex, + outW, + outH, + left, + top, + w, + h, + atlasRectOf(gp), + textColor, + strokeColor, + strokeWidth, + 0.0f, + pxRange); + } + } + + vkss::EndCmd(cmd, NOS_FALSE, nullptr); + return NOS_RESULT_SUCCESS; + } +}; + +static nosResult RegisterShaderPair(const fs::path& root, + const char* baseName, + nosName fragKey, + nosName vertKey) +{ + auto fragPath = (root / "Shaders" / (std::string(baseName) + ".frag")).generic_string(); + auto vertPath = (root / "Shaders" / (std::string(baseName) + ".vert")).generic_string(); + std::array shaders = { + nosShaderInfo{.ShaderName = fragKey, + .Source = {.Stage = NOS_SHADER_STAGE_FRAG, .GLSLPath = fragPath.c_str()}, + .AssociatedNodeClassName = NSN_TextRender}, + nosShaderInfo{.ShaderName = vertKey, + .Source = {.Stage = NOS_SHADER_STAGE_VERT, .GLSLPath = vertPath.c_str()}, + .AssociatedNodeClassName = NSN_TextRender}, + }; + return nosVulkan->RegisterShaders(shaders.size(), shaders.data()); +} + +nosResult RegisterTextRender(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NSN_TextRender, TextRenderNode, fn); + + fs::path root = nosEngine.Module->RootFolderPath; + if (nosResult ret = RegisterShaderPair(root, "TextGlyph", NSN_TextGlyph_Frag, NSN_TextGlyph_Vert); + ret != NOS_RESULT_SUCCESS) + return ret; + if (nosResult ret = RegisterShaderPair(root, "TextBox", NSN_TextBox_Frag, NSN_TextBox_Vert); + ret != NOS_RESULT_SUCCESS) + return ret; + + std::array passes = { + nosPassInfo{ + .Key = NSN_TextGlyph_Pass, + .Shader = NSN_TextGlyph_Frag, + .VertexShader = NSN_TextGlyph_Vert, + .MultiSample = 1, + .Blend = NOS_BLEND_MODE_ALPHA_BLENDING, + }, + nosPassInfo{ + .Key = NSN_TextBox_Pass, + .Shader = NSN_TextBox_Frag, + .VertexShader = NSN_TextBox_Vert, + .MultiSample = 1, + .Blend = NOS_BLEND_MODE_ALPHA_BLENDING, + }, + }; + return nosVulkan->RegisterPasses(passes.size(), passes.data()); +} + +} // namespace nos::utilities diff --git a/Plugins/nosUtilities/Source/UtilitiesMain.cpp b/Plugins/nosUtilities/Source/UtilitiesMain.cpp index 826732ca..ec492400 100644 --- a/Plugins/nosUtilities/Source/UtilitiesMain.cpp +++ b/Plugins/nosUtilities/Source/UtilitiesMain.cpp @@ -60,6 +60,7 @@ enum Utilities : int LoadCubeLUT, RepeatingJunction, MultiLiveOut, + TextRender, Count }; @@ -99,6 +100,7 @@ nosResult RegisterGridOutputLayout(nosNodeFunctions*); nosResult RegisterLoadCubeLUT(nosNodeFunctions*); nosResult RegisterRepeatingJunction(nosNodeFunctions*); nosResult RegisterMultiLiveOut(nosNodeFunctions*); +nosResult RegisterTextRender(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -154,6 +156,7 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou GEN_CASE_NODE(LoadCubeLUT) GEN_CASE_NODE(RepeatingJunction) GEN_CASE_NODE(MultiLiveOut) + GEN_CASE_NODE(TextRender) } } return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosUtilities/Utilities.noscfg b/Plugins/nosUtilities/Utilities.noscfg index b83efd55..997acc29 100644 --- a/Plugins/nosUtilities/Utilities.noscfg +++ b/Plugins/nosUtilities/Utilities.noscfg @@ -66,7 +66,8 @@ "Config/YADIF.nosdef", "Config/YADIFWithAutoDispatchSize.nosdef", "Config/RepeatingJunction.nosdef", - "Config/MultiLiveOut.nosdef" + "Config/MultiLiveOut.nosdef", + "Config/TextRender.nosdef" ], "custom_types": [ "Config/Merge.fbs", @@ -76,7 +77,8 @@ "Config/TextureSwitcher.fbs", "Config/ChannelViewer.fbs", "Config/Sink.fbs", - "Config/Layout.fbs" + "Config/Layout.fbs", + "Config/TextRender.fbs" ], "defaults": [ "Config/Defaults.json" From e687d93eac12aa624cbf0275ecea5d8fd752a987 Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Wed, 20 May 2026 14:58:10 +0300 Subject: [PATCH 29/30] nos.utilities: Add ScheduleRequest node --- .../Config/ScheduleRequest.nosdef | 58 ++++++++++++++ .../nosUtilities/Source/ScheduleRequest.cpp | 80 +++++++++++++++++++ Plugins/nosUtilities/Source/UtilitiesMain.cpp | 3 + Plugins/nosUtilities/Utilities.noscfg | 3 +- 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 Plugins/nosUtilities/Config/ScheduleRequest.nosdef create mode 100644 Plugins/nosUtilities/Source/ScheduleRequest.cpp diff --git a/Plugins/nosUtilities/Config/ScheduleRequest.nosdef b/Plugins/nosUtilities/Config/ScheduleRequest.nosdef new file mode 100644 index 00000000..59042dbe --- /dev/null +++ b/Plugins/nosUtilities/Config/ScheduleRequest.nosdef @@ -0,0 +1,58 @@ +{ + "nodes": [ + { + "class_name": "ScheduleRequest", + "menu_info": { + "category": "Execution", + "display_name": "Schedule Request", + "aliases": [ "schedule", "on demand", "request" ] + }, + "node": { + "class_name": "ScheduleRequest", + "display_name": "Schedule Request", + "contents_type": "Job", + "description": "Drives an on-demand path. Each execution, and each path start, queues another\nschedule request so the path feeding Trigger keeps running. Wire the resource you\nwant scheduled into Sink.", + "pins": [ + { + "name": "Trigger", + "type_name": "nos.exe", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "Sink", + "type_name": "nos.Generic", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_ONLY" + }, + { + "name": "DeltaSeconds", + "display_name": "Delta Seconds", + "description": "Target time between path runs, as a rational x/y seconds. Default 1/60.", + "type_name": "nos.fb.vec2u", + "show_as": "INPUT_PIN", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": { "x": 1, "y": 60 } + }, + { + "name": "Importance", + "description": "Conflicting paths are controlled by the node of higher importance.", + "type_name": "uint", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": 1 + }, + { + "name": "TryAgainOnFailure", + "display_name": "Try Again On Failure", + "description": "If enabled, the request is retried when a node on the path returns an error.", + "type_name": "bool", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": true + } + ] + } + } + ] +} diff --git a/Plugins/nosUtilities/Source/ScheduleRequest.cpp b/Plugins/nosUtilities/Source/ScheduleRequest.cpp new file mode 100644 index 00000000..4b21f93b --- /dev/null +++ b/Plugins/nosUtilities/Source/ScheduleRequest.cpp @@ -0,0 +1,80 @@ +// Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +#include + +namespace nos::utilities +{ + +// Drives an on-demand path: each execution (and each path start) queues another +// schedule request, so the path feeding the Trigger pin keeps running. Wire the +// thing you want scheduled into Sink. Ported from nos.flow (dev branch). +struct ScheduleRequestNode : NodeContext +{ + bool TryAgainOnFailure = true; + nosVec2u DeltaSeconds = { 1, 60 }; + uint32_t Importance = 1; + + ScheduleRequestNode(nosFbNodePtr node) : NodeContext(node) + { + if (node->pins()) + for (auto* pin : *node->pins()) + { + auto* data = pin->data(); + if (data && data->size()) + ReadPin(nos::Name(pin->name()->c_str()), data->data()); + } + } + + void ReadPin(nos::Name name, const void* data) + { + if (name == NOS_NAME("DeltaSeconds")) + DeltaSeconds = *static_cast(data); + else if (name == NOS_NAME("Importance")) + Importance = *static_cast(data); + else if (name == NOS_NAME("TryAgainOnFailure")) + TryAgainOnFailure = *static_cast(data); + } + + void OnPinValueChanged(nos::Name pinName, uuid const& pinId, nosBuffer value) override + { + ReadPin(pinName, value.Data); + } + + void GetScheduleInfo(nosScheduleInfo* info) override + { + info->Type = NOS_SCHEDULE_TYPE_ON_DEMAND; + info->DeltaSeconds = DeltaSeconds; + info->Importance = Importance; + } + + void ScheduleOnce() + { + nosScheduleNodeParams params{ .NodeId = NodeId, .AddScheduleCount = 1, .Reset = false }; + nosEngine.ScheduleNode(¶ms); + } + + nosResult ExecuteNode(nosNodeExecuteParams* params) override + { + ScheduleOnce(); + return NOS_RESULT_SUCCESS; + } + + void OnPathStart() override + { + ScheduleOnce(); + } + + void OnEndFrame(uuid const& pinId, nosEndFrameCause cause) override + { + if (TryAgainOnFailure && cause == NOS_END_FRAME_FAILED) + ScheduleOnce(); + } +}; + +nosResult RegisterScheduleRequest(nosNodeFunctions* fn) +{ + NOS_BIND_NODE_CLASS(NOS_NAME("ScheduleRequest"), ScheduleRequestNode, fn); + return NOS_RESULT_SUCCESS; +} + +} // namespace nos::utilities diff --git a/Plugins/nosUtilities/Source/UtilitiesMain.cpp b/Plugins/nosUtilities/Source/UtilitiesMain.cpp index ec492400..ad07f07c 100644 --- a/Plugins/nosUtilities/Source/UtilitiesMain.cpp +++ b/Plugins/nosUtilities/Source/UtilitiesMain.cpp @@ -61,6 +61,7 @@ enum Utilities : int RepeatingJunction, MultiLiveOut, TextRender, + ScheduleRequest, Count }; @@ -101,6 +102,7 @@ nosResult RegisterLoadCubeLUT(nosNodeFunctions*); nosResult RegisterRepeatingJunction(nosNodeFunctions*); nosResult RegisterMultiLiveOut(nosNodeFunctions*); nosResult RegisterTextRender(nosNodeFunctions*); +nosResult RegisterScheduleRequest(nosNodeFunctions*); nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** outList) { @@ -157,6 +159,7 @@ nosResult NOSAPI_CALL ExportNodeFunctions(size_t* outSize, nosNodeFunctions** ou GEN_CASE_NODE(RepeatingJunction) GEN_CASE_NODE(MultiLiveOut) GEN_CASE_NODE(TextRender) + GEN_CASE_NODE(ScheduleRequest) } } return NOS_RESULT_SUCCESS; diff --git a/Plugins/nosUtilities/Utilities.noscfg b/Plugins/nosUtilities/Utilities.noscfg index 997acc29..50864c54 100644 --- a/Plugins/nosUtilities/Utilities.noscfg +++ b/Plugins/nosUtilities/Utilities.noscfg @@ -67,7 +67,8 @@ "Config/YADIFWithAutoDispatchSize.nosdef", "Config/RepeatingJunction.nosdef", "Config/MultiLiveOut.nosdef", - "Config/TextRender.nosdef" + "Config/TextRender.nosdef", + "Config/ScheduleRequest.nosdef" ], "custom_types": [ "Config/Merge.fbs", From 6e998a51b5df618a961433a3c7e226932c2c17cb Mon Sep 17 00:00:00 2001 From: "M. Samil Atesoglu" Date: Mon, 1 Jun 2026 18:49:45 +0300 Subject: [PATCH 30/30] Add nos.geometry FBX reader and Convert Transform node nos.geometry: new plugin with a Read FBX Transform node that reads an .fbx (via vendored OpenFBX) and outputs a selected object's local and global nos.fb.Transform. The object is chosen from a populated combo box, and a SourceFrame property selects the CoordinateFrame the file is authored in so the rotation is expressed consistently. nosTrack: new Convert Transform node that converts a nos.fb.Transform between CoordinateFrames (SourceFrame/TargetFrame) with a WorldScale for unit conversion (e.g. cm<->m); mirrors the existing TrackTransform node. nos.sys.track: move CoordinateFrameConv.h into the subsystem's public include so nosTrack and nos.geometry share a single copy of the frame/Euler math. --- Plugins/CMakeLists.txt | 1 + Plugins/nosGeometry/CMakeLists.txt | 33 + .../Config/ReadFBXTransform.nosdef | 78 + .../nosGeometry/External/openFBX/libdeflate.c | 4193 +++++++++++++++++ .../nosGeometry/External/openFBX/libdeflate.h | 411 ++ Plugins/nosGeometry/External/openFBX/ofbx.cpp | 4102 ++++++++++++++++ Plugins/nosGeometry/External/openFBX/ofbx.h | 804 ++++ .../nosGeometry/External/openFBX/readme.txt | 1 + Plugins/nosGeometry/Geometry.noscfg | 23 + Plugins/nosGeometry/Source/PluginMain.cpp | 31 + .../nosGeometry/Source/ReadFBXTransform.cpp | 240 + .../nosTrack/Config/ConvertTransform.nosdef | 56 + Plugins/nosTrack/Source/ConvertTransform.cpp | 59 + .../nosTrack/Source/PlaybackTrackCOLMAP.cpp | 2 +- Plugins/nosTrack/Source/RecordTrackCOLMAP.cpp | 2 +- Plugins/nosTrack/Source/TrackMain.cpp | 5 + Plugins/nosTrack/Source/TrackTransform.cpp | 2 +- Plugins/nosTrack/Track.noscfg | 3 +- .../nosSysTrack}/CoordinateFrameConv.h | 14 +- 19 files changed, 10052 insertions(+), 8 deletions(-) create mode 100644 Plugins/nosGeometry/CMakeLists.txt create mode 100644 Plugins/nosGeometry/Config/ReadFBXTransform.nosdef create mode 100644 Plugins/nosGeometry/External/openFBX/libdeflate.c create mode 100644 Plugins/nosGeometry/External/openFBX/libdeflate.h create mode 100644 Plugins/nosGeometry/External/openFBX/ofbx.cpp create mode 100644 Plugins/nosGeometry/External/openFBX/ofbx.h create mode 100644 Plugins/nosGeometry/External/openFBX/readme.txt create mode 100644 Plugins/nosGeometry/Geometry.noscfg create mode 100644 Plugins/nosGeometry/Source/PluginMain.cpp create mode 100644 Plugins/nosGeometry/Source/ReadFBXTransform.cpp create mode 100644 Plugins/nosTrack/Config/ConvertTransform.nosdef create mode 100644 Plugins/nosTrack/Source/ConvertTransform.cpp rename {Plugins/nosTrack/Source => Subsystems/nosTrackSubsystem/Include/nosSysTrack}/CoordinateFrameConv.h (88%) diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 589829f7..63685e54 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -42,6 +42,7 @@ add_subdirectory(nosReflect) add_subdirectory(nosStrings) add_subdirectory(nosAnimation) add_subdirectory(nosGraphics) +add_subdirectory(nosGeometry) nos_get_targets(PLUGINS_COMMON_EXTERNAL_TARGETS "./External") nos_group_targets("${PLUGINS_COMMON_EXTERNAL_TARGETS}" "External") diff --git a/Plugins/nosGeometry/CMakeLists.txt b/Plugins/nosGeometry/CMakeLists.txt new file mode 100644 index 00000000..82e8b4ed --- /dev/null +++ b/Plugins/nosGeometry/CMakeLists.txt @@ -0,0 +1,33 @@ +# Copyright MediaZ Teknoloji A.S. All Rights Reserved. + +# Vendored OpenFBX, used to read object transforms out of .fbx files. +# Built under a plugin-unique target name so it never clashes with the +# "openFBX" target the zd plugins create from their own copy. +if (NOT TARGET nosGeometry_openFBX) + add_library(nosGeometry_openFBX STATIC + External/openFBX/libdeflate.c + External/openFBX/ofbx.cpp) + target_include_directories(nosGeometry_openFBX PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/External/openFBX") + nos_group_targets("nosGeometry_openFBX" "External") +endif() + +# nos.sys.track provides the CoordinateFrame type and the shared +# CoordinateFrameConv.h helpers used to express FBX transforms in a frame +# convention that composes with nosTrack's Convert/Track Transform nodes. +set(MODULE_DEPENDENCIES "nos.sys.track-1.1") +set(dep_idx 0) +foreach(module_name_version ${MODULE_DEPENDENCIES}) + string(REPLACE "-" ";" module_name_version ${module_name_version}) + list(GET module_name_version 0 module_name) + list(GET module_name_version 1 module_version) + nos_get_module("${module_name}" "${module_version}" DEP_${dep_idx}) + list(APPEND MODULE_DEPENDENCIES_TARGETS ${DEP_${dep_idx}}) +endforeach() + +set(DEPENDENCIES ${NOS_PLUGIN_SDK_TARGET} nosGeometry_openFBX ${MODULE_DEPENDENCIES_TARGETS}) +set(INCLUDE_FOLDERS "") + +nos_add_plugin("nosGeometry" "${DEPENDENCIES}" "${INCLUDE_FOLDERS}") + +# Helpers need C++20 +set_target_properties("nosGeometry" PROPERTIES CXX_STANDARD 20) diff --git a/Plugins/nosGeometry/Config/ReadFBXTransform.nosdef b/Plugins/nosGeometry/Config/ReadFBXTransform.nosdef new file mode 100644 index 00000000..8950b04b --- /dev/null +++ b/Plugins/nosGeometry/Config/ReadFBXTransform.nosdef @@ -0,0 +1,78 @@ +{ + "nodes": [ + { + "class_name": "ReadFBXTransform", + "menu_info": { + "category": "Geometry", + "display_name": "Read FBX Transform", + "name_aliases": [ "FBX", "Load FBX", "FBX Transform", "FBX Reader" ] + }, + "node": { + "class_name": "ReadFBXTransform", + "contents_type": "Job", + "description": "Reads an .fbx file and outputs the local and global transform of a\nselected object inside it. Pick the object from the 'Object' dropdown,\nwhich is populated with the names found in the file.", + "pins": [ + { + "name": "Path", + "type_name": "string", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "visualizer": { + "type": "FILE_PICKER", + "file_extensions": [ "fbx" ], + "file_picker_type": "OPEN" + }, + "description": "Path to the .fbx file to read." + }, + { + "name": "Object", + "type_name": "string", + "show_as": "PROPERTY", + "can_show_as": "PROPERTY_ONLY", + "data": "", + "visualizer": { + "type": "COMBO_BOX", + "name": "" + }, + "description": "Object inside the .fbx whose transform is reported.\nThe list is populated once the file is loaded." + }, + { + "name": "SourceFrame", + "display_name": "Source Frame", + "type_name": "nos.sys.track.CoordinateFrame", + "show_as": "PROPERTY", + "can_show_as": "INPUT_PIN_OR_PROPERTY", + "data": "RH_YUp_FwdNegZ_RightX", + "description": "Coordinate frame the .fbx is authored in. The output transforms are expressed in this frame; set Convert Transform's 'SourceFrame' to the same value." + }, + { + "name": "LocalTransform", + "display_name": "Local Transform", + "type_name": "nos.fb.Transform", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "description": "Transform of the object relative to its parent." + }, + { + "name": "GlobalTransform", + "display_name": "Global Transform", + "type_name": "nos.fb.Transform", + "show_as": "OUTPUT_PIN", + "can_show_as": "OUTPUT_PIN_ONLY", + "description": "World transform of the object, accounting for its parent hierarchy." + }, + { + "name": "IsLoaded", + "display_name": "Is Loaded", + "type_name": "bool", + "show_as": "PROPERTY", + "can_show_as": "OUTPUT_PIN_OR_PROPERTY", + "data": false, + "readonly": true, + "description": "True if a valid 'Path' was selected and the file could be loaded." + } + ] + } + } + ] +} diff --git a/Plugins/nosGeometry/External/openFBX/libdeflate.c b/Plugins/nosGeometry/External/openFBX/libdeflate.c new file mode 100644 index 00000000..e421d791 --- /dev/null +++ b/Plugins/nosGeometry/External/openFBX/libdeflate.c @@ -0,0 +1,4193 @@ +// ofbx changes : removed unused code, single .h and .c +/* + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * --------------------------------------------------------------------------- + * + * This is a highly optimized DEFLATE decompressor. It is much faster than + * vanilla zlib, typically well over twice as fast, though results vary by CPU. + * + * Why this is faster than vanilla zlib: + * + * - Word accesses rather than byte accesses when reading input + * - Word accesses rather than byte accesses when copying matches + * - Faster Huffman decoding combined with various DEFLATE-specific tricks + * - Larger bitbuffer variable that doesn't need to be refilled as often + * - Other optimizations to remove unnecessary branches + * - Only full-buffer decompression is supported, so the code doesn't need to + * support stopping and resuming decompression. + * - On x86_64, a version of the decompression routine is compiled with BMI2 + * instructions enabled and is used automatically at runtime when supported. + */ + +/* + * lib_common.h - internal header included by all library code + */ + +#ifndef LIB_LIB_COMMON_H +#define LIB_LIB_COMMON_H + +#ifdef LIBDEFLATE_H + /* + * When building the library, LIBDEFLATEAPI needs to be defined properly before + * including libdeflate.h. + */ +# error "lib_common.h must always be included before libdeflate.h" +#endif + +#if defined(LIBDEFLATE_DLL) && (defined(_WIN32) || defined(__CYGWIN__)) +# define LIBDEFLATE_EXPORT_SYM __declspec(dllexport) +#elif defined(__GNUC__) +# define LIBDEFLATE_EXPORT_SYM __attribute__((visibility("default"))) +#else +# define LIBDEFLATE_EXPORT_SYM +#endif + +/* + * On i386, gcc assumes that the stack is 16-byte aligned at function entry. + * However, some compilers (e.g. MSVC) and programming languages (e.g. Delphi) + * only guarantee 4-byte alignment when calling functions. This is mainly an + * issue on Windows, but it has been seen on Linux too. Work around this ABI + * incompatibility by realigning the stack pointer when entering libdeflate. + * This prevents crashes in SSE/AVX code. + */ +#if defined(__GNUC__) && defined(__i386__) +# define LIBDEFLATE_ALIGN_STACK __attribute__((force_align_arg_pointer)) +#else +# define LIBDEFLATE_ALIGN_STACK +#endif + +#define LIBDEFLATEAPI LIBDEFLATE_EXPORT_SYM LIBDEFLATE_ALIGN_STACK + +/* + * common_defs.h + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#ifndef COMMON_DEFS_H +#define COMMON_DEFS_H + +#include "libdeflate.h" + +#include +#include /* for size_t */ +#include +#ifdef _MSC_VER +# include /* for _BitScan*() and other intrinsics */ +# include /* for _byteswap_*() */ + /* Disable MSVC warnings that are expected. */ + /* /W2 */ +# pragma warning(disable : 4146) /* unary minus on unsigned type */ + /* /W3 */ +# pragma warning(disable : 4018) /* signed/unsigned mismatch */ +# pragma warning(disable : 4244) /* possible loss of data */ +# pragma warning(disable : 4267) /* possible loss of precision */ +# pragma warning(disable : 4310) /* cast truncates constant value */ + /* /W4 */ +# pragma warning(disable : 4100) /* unreferenced formal parameter */ +# pragma warning(disable : 4127) /* conditional expression is constant */ +# pragma warning(disable : 4189) /* local variable initialized but not referenced */ +# pragma warning(disable : 4232) /* nonstandard extension used */ +# pragma warning(disable : 4245) /* conversion from 'int' to 'unsigned int' */ +# pragma warning(disable : 4295) /* array too small to include terminating null */ +#endif +#ifndef FREESTANDING +# include /* for memcpy() */ +#endif + +/* ========================================================================== */ +/* Target architecture */ +/* ========================================================================== */ + +/* If possible, define a compiler-independent ARCH_* macro. */ +#undef ARCH_X86_64 +#undef ARCH_X86_32 +#undef ARCH_ARM64 +#undef ARCH_ARM32 +#ifdef _MSC_VER +# if defined(_M_X64) +# define ARCH_X86_64 +# elif defined(_M_IX86) +# define ARCH_X86_32 +# elif defined(_M_ARM64) +# define ARCH_ARM64 +# elif defined(_M_ARM) +# define ARCH_ARM32 +# endif +#else +# if defined(__x86_64__) +# define ARCH_X86_64 +# elif defined(__i386__) +# define ARCH_X86_32 +# elif defined(__aarch64__) +# define ARCH_ARM64 +# elif defined(__arm__) +# define ARCH_ARM32 +# endif +#endif + +/* ========================================================================== */ +/* Type definitions */ +/* ========================================================================== */ + +/* Fixed-width integer types */ +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +typedef int64_t s64; + +/* ssize_t, if not available in */ +#ifdef _MSC_VER +# ifdef _WIN64 + typedef long long ssize_t; +# else + typedef long ssize_t; +# endif +#endif + +/* + * Word type of the target architecture. Use 'size_t' instead of + * 'unsigned long' to account for platforms such as Windows that use 32-bit + * 'unsigned long' on 64-bit architectures. + */ +typedef size_t machine_word_t; + +/* Number of bytes in a word */ +#define WORDBYTES ((int)sizeof(machine_word_t)) + +/* Number of bits in a word */ +#define WORDBITS (8 * WORDBYTES) + +/* ========================================================================== */ +/* Optional compiler features */ +/* ========================================================================== */ + +/* Compiler version checks. Only use when absolutely necessary. */ +#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTEL_COMPILER) +# define GCC_PREREQ(major, minor) \ + (__GNUC__ > (major) || \ + (__GNUC__ == (major) && __GNUC_MINOR__ >= (minor))) +#else +# define GCC_PREREQ(major, minor) 0 +#endif +#ifdef __clang__ +# ifdef __apple_build_version__ +# define CLANG_PREREQ(major, minor, apple_version) \ + (__apple_build_version__ >= (apple_version)) +# else +# define CLANG_PREREQ(major, minor, apple_version) \ + (__clang_major__ > (major) || \ + (__clang_major__ == (major) && __clang_minor__ >= (minor))) +# endif +#else +# define CLANG_PREREQ(major, minor, apple_version) 0 +#endif + +/* + * Macros to check for compiler support for attributes and builtins. clang + * implements these macros, but gcc doesn't, so generally any use of one of + * these macros must also be combined with a gcc version check. + */ +#ifndef __has_attribute +# define __has_attribute(attribute) 0 +#endif +#ifndef __has_builtin +# define __has_builtin(builtin) 0 +#endif + +/* inline - suggest that a function be inlined */ +#ifdef _MSC_VER +# define inline __inline +#endif /* else assume 'inline' is usable as-is */ + +/* forceinline - force a function to be inlined, if possible */ +#if defined(__GNUC__) || __has_attribute(always_inline) +# define forceinline inline __attribute__((always_inline)) +#elif defined(_MSC_VER) +# define forceinline __forceinline +#else +# define forceinline inline +#endif + +/* MAYBE_UNUSED - mark a function or variable as maybe unused */ +#if defined(__GNUC__) || __has_attribute(unused) +# define MAYBE_UNUSED __attribute__((unused)) +#else +# define MAYBE_UNUSED +#endif + +/* + * restrict - hint that writes only occur through the given pointer. + * + * Don't use MSVC's __restrict, since it has nonstandard behavior. + * Standard restrict is okay, if it is supported. + */ +#if !defined(__STDC_VERSION__) || (__STDC_VERSION__ < 201112L) +# if defined(__GNUC__) || defined(__clang__) +# define restrict __restrict__ +# else +# define restrict +# endif +#endif /* else assume 'restrict' is usable as-is */ + +/* likely(expr) - hint that an expression is usually true */ +#if defined(__GNUC__) || __has_builtin(__builtin_expect) +# define likely(expr) __builtin_expect(!!(expr), 1) +#else +# define likely(expr) (expr) +#endif + +/* unlikely(expr) - hint that an expression is usually false */ +#if defined(__GNUC__) || __has_builtin(__builtin_expect) +# define unlikely(expr) __builtin_expect(!!(expr), 0) +#else +# define unlikely(expr) (expr) +#endif + +/* prefetchr(addr) - prefetch into L1 cache for read */ +#undef prefetchr +#if defined(__GNUC__) || __has_builtin(__builtin_prefetch) +# define prefetchr(addr) __builtin_prefetch((addr), 0) +#elif defined(_MSC_VER) +# if defined(ARCH_X86_32) || defined(ARCH_X86_64) +# define prefetchr(addr) _mm_prefetch((addr), _MM_HINT_T0) +# elif defined(ARCH_ARM64) +# define prefetchr(addr) __prefetch2((addr), 0x00 /* prfop=PLDL1KEEP */) +# elif defined(ARCH_ARM32) +# define prefetchr(addr) __prefetch(addr) +# endif +#endif +#ifndef prefetchr +# define prefetchr(addr) +#endif + +/* prefetchw(addr) - prefetch into L1 cache for write */ +#undef prefetchw +#if defined(__GNUC__) || __has_builtin(__builtin_prefetch) +# define prefetchw(addr) __builtin_prefetch((addr), 1) +#elif defined(_MSC_VER) +# if defined(ARCH_X86_32) || defined(ARCH_X86_64) +# define prefetchw(addr) _m_prefetchw(addr) +# elif defined(ARCH_ARM64) +# define prefetchw(addr) __prefetch2((addr), 0x10 /* prfop=PSTL1KEEP */) +# elif defined(ARCH_ARM32) +# define prefetchw(addr) __prefetchw(addr) +# endif +#endif +#ifndef prefetchw +# define prefetchw(addr) +#endif + +/* + * _aligned_attribute(n) - declare that the annotated variable, or variables of + * the annotated type, must be aligned on n-byte boundaries. + */ +#undef _aligned_attribute +#if defined(__GNUC__) || __has_attribute(aligned) +# define _aligned_attribute(n) __attribute__((aligned(n))) +#elif defined(_MSC_VER) +# define _aligned_attribute(n) __declspec(align(n)) +#endif + +/* + * _target_attribute(attrs) - override the compilation target for a function. + * + * This accepts one or more comma-separated suffixes to the -m prefix jointly + * forming the name of a machine-dependent option. On gcc-like compilers, this + * enables codegen for the given targets, including arbitrary compiler-generated + * code as well as the corresponding intrinsics. On other compilers this macro + * expands to nothing, though MSVC allows intrinsics to be used anywhere anyway. + */ +#if GCC_PREREQ(4, 4) || __has_attribute(target) +# define _target_attribute(attrs) __attribute__((target(attrs))) +# define COMPILER_SUPPORTS_TARGET_FUNCTION_ATTRIBUTE 1 +#else +# define _target_attribute(attrs) +# define COMPILER_SUPPORTS_TARGET_FUNCTION_ATTRIBUTE 0 +#endif + +/* ========================================================================== */ +/* Miscellaneous macros */ +/* ========================================================================== */ + +#define ARRAY_LEN(A) (sizeof(A) / sizeof((A)[0])) +#define MIN(a, b) ((a) <= (b) ? (a) : (b)) +#define MAX(a, b) ((a) >= (b) ? (a) : (b)) +#define DIV_ROUND_UP(n, d) (((n) + (d) - 1) / (d)) +#define STATIC_ASSERT(expr) ((void)sizeof(char[1 - 2 * !(expr)])) +#define ALIGN(n, a) (((n) + (a) - 1) & ~((a) - 1)) +#define ROUND_UP(n, d) ((d) * DIV_ROUND_UP((n), (d))) + +/* ========================================================================== */ +/* Endianness handling */ +/* ========================================================================== */ + +/* + * CPU_IS_LITTLE_ENDIAN() - 1 if the CPU is little endian, or 0 if it is big + * endian. When possible this is a compile-time macro that can be used in + * preprocessor conditionals. As a fallback, a generic method is used that + * can't be used in preprocessor conditionals but should still be optimized out. + */ +#if defined(__BYTE_ORDER__) /* gcc v4.6+ and clang */ +# define CPU_IS_LITTLE_ENDIAN() (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) +#elif defined(_MSC_VER) +# define CPU_IS_LITTLE_ENDIAN() true +#else +static forceinline bool CPU_IS_LITTLE_ENDIAN(void) +{ + union { + u32 w; + u8 b; + } u; + + u.w = 1; + return u.b; +} +#endif + +/* bswap16(v) - swap the bytes of a 16-bit integer */ +static forceinline u16 bswap16(u16 v) +{ +#if GCC_PREREQ(4, 8) || __has_builtin(__builtin_bswap16) + return __builtin_bswap16(v); +#elif defined(_MSC_VER) + return _byteswap_ushort(v); +#else + return (v << 8) | (v >> 8); +#endif +} + +/* bswap32(v) - swap the bytes of a 32-bit integer */ +static forceinline u32 bswap32(u32 v) +{ +#if GCC_PREREQ(4, 3) || __has_builtin(__builtin_bswap32) + return __builtin_bswap32(v); +#elif defined(_MSC_VER) + return _byteswap_ulong(v); +#else + return ((v & 0x000000FF) << 24) | + ((v & 0x0000FF00) << 8) | + ((v & 0x00FF0000) >> 8) | + ((v & 0xFF000000) >> 24); +#endif +} + +/* bswap64(v) - swap the bytes of a 64-bit integer */ +static forceinline u64 bswap64(u64 v) +{ +#if GCC_PREREQ(4, 3) || __has_builtin(__builtin_bswap64) + return __builtin_bswap64(v); +#elif defined(_MSC_VER) + return _byteswap_uint64(v); +#else + return ((v & 0x00000000000000FF) << 56) | + ((v & 0x000000000000FF00) << 40) | + ((v & 0x0000000000FF0000) << 24) | + ((v & 0x00000000FF000000) << 8) | + ((v & 0x000000FF00000000) >> 8) | + ((v & 0x0000FF0000000000) >> 24) | + ((v & 0x00FF000000000000) >> 40) | + ((v & 0xFF00000000000000) >> 56); +#endif +} + +#define le16_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? (v) : bswap16(v)) +#define le32_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? (v) : bswap32(v)) +#define le64_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? (v) : bswap64(v)) +#define be16_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? bswap16(v) : (v)) +#define be32_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? bswap32(v) : (v)) +#define be64_bswap(v) (CPU_IS_LITTLE_ENDIAN() ? bswap64(v) : (v)) + +/* ========================================================================== */ +/* Unaligned memory accesses */ +/* ========================================================================== */ + +/* + * UNALIGNED_ACCESS_IS_FAST() - 1 if unaligned memory accesses can be performed + * efficiently on the target platform, otherwise 0. + */ +#if (defined(__GNUC__) || defined(__clang__)) && \ + (defined(ARCH_X86_64) || defined(ARCH_X86_32) || \ + defined(__ARM_FEATURE_UNALIGNED) || defined(__powerpc64__) || \ + /* + * For all compilation purposes, WebAssembly behaves like any other CPU + * instruction set. Even though WebAssembly engine might be running on + * top of different actual CPU architectures, the WebAssembly spec + * itself permits unaligned access and it will be fast on most of those + * platforms, and simulated at the engine level on others, so it's + * worth treating it as a CPU architecture with fast unaligned access. + */ defined(__wasm__)) +# define UNALIGNED_ACCESS_IS_FAST 1 +#elif defined(_MSC_VER) +# define UNALIGNED_ACCESS_IS_FAST 1 +#else +# define UNALIGNED_ACCESS_IS_FAST 0 +#endif + +/* + * Implementing unaligned memory accesses using memcpy() is portable, and it + * usually gets optimized appropriately by modern compilers. I.e., each + * memcpy() of 1, 2, 4, or WORDBYTES bytes gets compiled to a load or store + * instruction, not to an actual function call. + * + * We no longer use the "packed struct" approach to unaligned accesses, as that + * is nonstandard, has unclear semantics, and doesn't receive enough testing + * (see https://gcc.gnu.org/bugzilla/show_bug.cgi?id=94994). + * + * arm32 with __ARM_FEATURE_UNALIGNED in gcc 5 and earlier is a known exception + * where memcpy() generates inefficient code + * (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67366). However, we no longer + * consider that one case important enough to maintain different code for. + * If you run into it, please just use a newer version of gcc (or use clang). + */ + +#ifdef FREESTANDING +# define MEMCOPY __builtin_memcpy +#else +# define MEMCOPY memcpy +#endif + +/* Unaligned loads and stores without endianness conversion */ + +#define DEFINE_UNALIGNED_TYPE(type) \ +static forceinline type \ +load_##type##_unaligned(const void *p) \ +{ \ + type v; \ + \ + MEMCOPY(&v, p, sizeof(v)); \ + return v; \ +} \ + \ +static forceinline void \ +store_##type##_unaligned(type v, void *p) \ +{ \ + MEMCOPY(p, &v, sizeof(v)); \ +} + +DEFINE_UNALIGNED_TYPE(u16) +DEFINE_UNALIGNED_TYPE(u32) +DEFINE_UNALIGNED_TYPE(u64) +DEFINE_UNALIGNED_TYPE(machine_word_t) + +#undef MEMCOPY + +#define load_word_unaligned load_machine_word_t_unaligned +#define store_word_unaligned store_machine_word_t_unaligned + +/* Unaligned loads with endianness conversion */ + +static forceinline u16 +get_unaligned_le16(const u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) + return le16_bswap(load_u16_unaligned(p)); + else + return ((u16)p[1] << 8) | p[0]; +} + +static forceinline u16 +get_unaligned_be16(const u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) + return be16_bswap(load_u16_unaligned(p)); + else + return ((u16)p[0] << 8) | p[1]; +} + +static forceinline u32 +get_unaligned_le32(const u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) + return le32_bswap(load_u32_unaligned(p)); + else + return ((u32)p[3] << 24) | ((u32)p[2] << 16) | + ((u32)p[1] << 8) | p[0]; +} + +static forceinline u32 +get_unaligned_be32(const u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) + return be32_bswap(load_u32_unaligned(p)); + else + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | + ((u32)p[2] << 8) | p[3]; +} + +static forceinline u64 +get_unaligned_le64(const u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) + return le64_bswap(load_u64_unaligned(p)); + else + return ((u64)p[7] << 56) | ((u64)p[6] << 48) | + ((u64)p[5] << 40) | ((u64)p[4] << 32) | + ((u64)p[3] << 24) | ((u64)p[2] << 16) | + ((u64)p[1] << 8) | p[0]; +} + +static forceinline machine_word_t +get_unaligned_leword(const u8 *p) +{ + STATIC_ASSERT(WORDBITS == 32 || WORDBITS == 64); + if (WORDBITS == 32) + return get_unaligned_le32(p); + else + return get_unaligned_le64(p); +} + +/* Unaligned stores with endianness conversion */ + +static forceinline void +put_unaligned_le16(u16 v, u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) { + store_u16_unaligned(le16_bswap(v), p); + } else { + p[0] = (u8)(v >> 0); + p[1] = (u8)(v >> 8); + } +} + +static forceinline void +put_unaligned_be16(u16 v, u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) { + store_u16_unaligned(be16_bswap(v), p); + } else { + p[0] = (u8)(v >> 8); + p[1] = (u8)(v >> 0); + } +} + +static forceinline void +put_unaligned_le32(u32 v, u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) { + store_u32_unaligned(le32_bswap(v), p); + } else { + p[0] = (u8)(v >> 0); + p[1] = (u8)(v >> 8); + p[2] = (u8)(v >> 16); + p[3] = (u8)(v >> 24); + } +} + +static forceinline void +put_unaligned_be32(u32 v, u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) { + store_u32_unaligned(be32_bswap(v), p); + } else { + p[0] = (u8)(v >> 24); + p[1] = (u8)(v >> 16); + p[2] = (u8)(v >> 8); + p[3] = (u8)(v >> 0); + } +} + +static forceinline void +put_unaligned_le64(u64 v, u8 *p) +{ + if (UNALIGNED_ACCESS_IS_FAST) { + store_u64_unaligned(le64_bswap(v), p); + } else { + p[0] = (u8)(v >> 0); + p[1] = (u8)(v >> 8); + p[2] = (u8)(v >> 16); + p[3] = (u8)(v >> 24); + p[4] = (u8)(v >> 32); + p[5] = (u8)(v >> 40); + p[6] = (u8)(v >> 48); + p[7] = (u8)(v >> 56); + } +} + +static forceinline void +put_unaligned_leword(machine_word_t v, u8 *p) +{ + STATIC_ASSERT(WORDBITS == 32 || WORDBITS == 64); + if (WORDBITS == 32) + put_unaligned_le32(v, p); + else + put_unaligned_le64(v, p); +} + +/* ========================================================================== */ +/* Bit manipulation functions */ +/* ========================================================================== */ + +/* + * Bit Scan Reverse (BSR) - find the 0-based index (relative to the least + * significant end) of the *most* significant 1 bit in the input value. The + * input value must be nonzero! + */ + +static forceinline unsigned +bsr32(u32 v) +{ +#if defined(__GNUC__) || __has_builtin(__builtin_clz) + return 31 - __builtin_clz(v); +#elif defined(_MSC_VER) + unsigned long i; + + _BitScanReverse(&i, v); + return i; +#else + unsigned i = 0; + + while ((v >>= 1) != 0) + i++; + return i; +#endif +} + +static forceinline unsigned +bsr64(u64 v) +{ +#if defined(__GNUC__) || __has_builtin(__builtin_clzll) + return 63 - __builtin_clzll(v); +#elif defined(_MSC_VER) && defined(_WIN64) + unsigned long i; + + _BitScanReverse64(&i, v); + return i; +#else + unsigned i = 0; + + while ((v >>= 1) != 0) + i++; + return i; +#endif +} + +static forceinline unsigned +bsrw(machine_word_t v) +{ + STATIC_ASSERT(WORDBITS == 32 || WORDBITS == 64); + if (WORDBITS == 32) + return bsr32(v); + else + return bsr64(v); +} + +/* + * Bit Scan Forward (BSF) - find the 0-based index (relative to the least + * significant end) of the *least* significant 1 bit in the input value. The + * input value must be nonzero! + */ + +static forceinline unsigned +bsf32(u32 v) +{ +#if defined(__GNUC__) || __has_builtin(__builtin_ctz) + return __builtin_ctz(v); +#elif defined(_MSC_VER) + unsigned long i; + + _BitScanForward(&i, v); + return i; +#else + unsigned i = 0; + + for (; (v & 1) == 0; v >>= 1) + i++; + return i; +#endif +} + +static forceinline unsigned +bsf64(u64 v) +{ +#if defined(__GNUC__) || __has_builtin(__builtin_ctzll) + return __builtin_ctzll(v); +#elif defined(_MSC_VER) && defined(_WIN64) + unsigned long i; + + _BitScanForward64(&i, v); + return i; +#else + unsigned i = 0; + + for (; (v & 1) == 0; v >>= 1) + i++; + return i; +#endif +} + +static forceinline unsigned +bsfw(machine_word_t v) +{ + STATIC_ASSERT(WORDBITS == 32 || WORDBITS == 64); + if (WORDBITS == 32) + return bsf32(v); + else + return bsf64(v); +} + +/* + * rbit32(v): reverse the bits in a 32-bit integer. This doesn't have a + * fallback implementation; use '#ifdef rbit32' to check if this is available. + */ +#undef rbit32 +#if (defined(__GNUC__) || defined(__clang__)) && defined(ARCH_ARM32) && \ + (__ARM_ARCH >= 7 || (__ARM_ARCH == 6 && defined(__ARM_ARCH_6T2__))) +static forceinline u32 +rbit32(u32 v) +{ + __asm__("rbit %0, %1" : "=r" (v) : "r" (v)); + return v; +} +#define rbit32 rbit32 +#elif (defined(__GNUC__) || defined(__clang__)) && defined(ARCH_ARM64) +static forceinline u32 +rbit32(u32 v) +{ + __asm__("rbit %w0, %w1" : "=r" (v) : "r" (v)); + return v; +} +#define rbit32 rbit32 +#endif + +#endif /* COMMON_DEFS_H */ + + +typedef void *(*malloc_func_t)(size_t); +typedef void (*free_func_t)(void *); + +extern malloc_func_t libdeflate_default_malloc_func; +extern free_func_t libdeflate_default_free_func; + +void *libdeflate_aligned_malloc(malloc_func_t malloc_func, + size_t alignment, size_t size); +void libdeflate_aligned_free(free_func_t free_func, void *ptr); + +#ifdef FREESTANDING +/* + * With -ffreestanding, may be missing, and we must provide + * implementations of memset(), memcpy(), memmove(), and memcmp(). + * See https://gcc.gnu.org/onlinedocs/gcc/Standards.html + * + * Also, -ffreestanding disables interpreting calls to these functions as + * built-ins. E.g., calling memcpy(&v, p, WORDBYTES) will make a function call, + * not be optimized to a single load instruction. For performance reasons we + * don't want that. So, declare these functions as macros that expand to the + * corresponding built-ins. This approach is recommended in the gcc man page. + * We still need the actual function definitions in case gcc calls them. + */ +void *memset(void *s, int c, size_t n); +#define memset(s, c, n) __builtin_memset((s), (c), (n)) + +void *memcpy(void *dest, const void *src, size_t n); +#define memcpy(dest, src, n) __builtin_memcpy((dest), (src), (n)) + +void *memmove(void *dest, const void *src, size_t n); +#define memmove(dest, src, n) __builtin_memmove((dest), (src), (n)) + +int memcmp(const void *s1, const void *s2, size_t n); +#define memcmp(s1, s2, n) __builtin_memcmp((s1), (s2), (n)) + +#undef LIBDEFLATE_ENABLE_ASSERTIONS +#else +#include +#endif + +/* + * Runtime assertion support. Don't enable this in production builds; it may + * hurt performance significantly. + */ +#ifdef LIBDEFLATE_ENABLE_ASSERTIONS +void libdeflate_assertion_failed(const char *expr, const char *file, int line); +#define ASSERT(expr) { if (unlikely(!(expr))) \ + libdeflate_assertion_failed(#expr, __FILE__, __LINE__); } +#else +#define ASSERT(expr) (void)(expr) +#endif + +#define CONCAT_IMPL(a, b) a##b +#define CONCAT(a, b) CONCAT_IMPL(a, b) +#define ADD_SUFFIX(name) CONCAT(name, SUFFIX) + +#endif /* LIB_LIB_COMMON_H */ + +/* + * deflate_constants.h - constants for the DEFLATE compression format + */ + +#ifndef LIB_DEFLATE_CONSTANTS_H +#define LIB_DEFLATE_CONSTANTS_H + +/* Valid block types */ +#define DEFLATE_BLOCKTYPE_UNCOMPRESSED 0 +#define DEFLATE_BLOCKTYPE_STATIC_HUFFMAN 1 +#define DEFLATE_BLOCKTYPE_DYNAMIC_HUFFMAN 2 + +/* Minimum and maximum supported match lengths (in bytes) */ +#define DEFLATE_MIN_MATCH_LEN 3 +#define DEFLATE_MAX_MATCH_LEN 258 + +/* Maximum supported match offset (in bytes) */ +#define DEFLATE_MAX_MATCH_OFFSET 32768 + +/* log2 of DEFLATE_MAX_MATCH_OFFSET */ +#define DEFLATE_WINDOW_ORDER 15 + +/* Number of symbols in each Huffman code. Note: for the literal/length + * and offset codes, these are actually the maximum values; a given block + * might use fewer symbols. */ +#define DEFLATE_NUM_PRECODE_SYMS 19 +#define DEFLATE_NUM_LITLEN_SYMS 288 +#define DEFLATE_NUM_OFFSET_SYMS 32 + +/* The maximum number of symbols across all codes */ +#define DEFLATE_MAX_NUM_SYMS 288 + +/* Division of symbols in the literal/length code */ +#define DEFLATE_NUM_LITERALS 256 +#define DEFLATE_END_OF_BLOCK 256 +#define DEFLATE_FIRST_LEN_SYM 257 + +/* Maximum codeword length, in bits, within each Huffman code */ +#define DEFLATE_MAX_PRE_CODEWORD_LEN 7 +#define DEFLATE_MAX_LITLEN_CODEWORD_LEN 15 +#define DEFLATE_MAX_OFFSET_CODEWORD_LEN 15 + +/* The maximum codeword length across all codes */ +#define DEFLATE_MAX_CODEWORD_LEN 15 + +/* Maximum possible overrun when decoding codeword lengths */ +#define DEFLATE_MAX_LENS_OVERRUN 137 + +/* + * Maximum number of extra bits that may be required to represent a match + * length or offset. + */ +#define DEFLATE_MAX_EXTRA_LENGTH_BITS 5 +#define DEFLATE_MAX_EXTRA_OFFSET_BITS 13 + +#endif /* LIB_DEFLATE_CONSTANTS_H */ + +/* + * cpu_features_common.h - code shared by all lib/$arch/cpu_features.c + * + * Copyright 2020 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#ifndef LIB_CPU_FEATURES_COMMON_H +#define LIB_CPU_FEATURES_COMMON_H + +#if defined(TEST_SUPPORT__DO_NOT_USE) && !defined(FREESTANDING) + /* for strdup() and strtok_r() */ +# undef _ANSI_SOURCE +# ifndef __APPLE__ +# undef _GNU_SOURCE +# define _GNU_SOURCE +# endif +# include +# include +# include +#endif + +struct cpu_feature { + u32 bit; + const char *name; +}; + +#if defined(TEST_SUPPORT__DO_NOT_USE) && !defined(FREESTANDING) +/* Disable any features that are listed in $LIBDEFLATE_DISABLE_CPU_FEATURES. */ +static inline void +disable_cpu_features_for_testing(u32 *features, + const struct cpu_feature *feature_table, + size_t feature_table_length) +{ + char *env_value, *strbuf, *p, *saveptr = NULL; + size_t i; + + env_value = getenv("LIBDEFLATE_DISABLE_CPU_FEATURES"); + if (!env_value) + return; + strbuf = strdup(env_value); + if (!strbuf) + abort(); + p = strtok_r(strbuf, ",", &saveptr); + while (p) { + for (i = 0; i < feature_table_length; i++) { + if (strcmp(p, feature_table[i].name) == 0) { + *features &= ~feature_table[i].bit; + break; + } + } + if (i == feature_table_length) { + fprintf(stderr, + "unrecognized feature in LIBDEFLATE_DISABLE_CPU_FEATURES: \"%s\"\n", + p); + abort(); + } + p = strtok_r(NULL, ",", &saveptr); + } + free(strbuf); +} +#else /* TEST_SUPPORT__DO_NOT_USE */ +static inline void +disable_cpu_features_for_testing(u32 *features, + const struct cpu_feature *feature_table, + size_t feature_table_length) +{ +} +#endif /* !TEST_SUPPORT__DO_NOT_USE */ + +#endif /* LIB_CPU_FEATURES_COMMON_H */ + +/* + * x86/cpu_features.h - feature detection for x86 CPUs + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#ifndef LIB_X86_CPU_FEATURES_H +#define LIB_X86_CPU_FEATURES_H + +#define HAVE_DYNAMIC_X86_CPU_FEATURES 0 + +#if defined(ARCH_X86_32) || defined(ARCH_X86_64) + +#if COMPILER_SUPPORTS_TARGET_FUNCTION_ATTRIBUTE || defined(_MSC_VER) +# undef HAVE_DYNAMIC_X86_CPU_FEATURES +# define HAVE_DYNAMIC_X86_CPU_FEATURES 1 +#endif + +#define X86_CPU_FEATURE_SSE2 0x00000001 +#define X86_CPU_FEATURE_PCLMUL 0x00000002 +#define X86_CPU_FEATURE_AVX 0x00000004 +#define X86_CPU_FEATURE_AVX2 0x00000008 +#define X86_CPU_FEATURE_BMI2 0x00000010 + +#define HAVE_SSE2(features) (HAVE_SSE2_NATIVE || ((features) & X86_CPU_FEATURE_SSE2)) +#define HAVE_PCLMUL(features) (HAVE_PCLMUL_NATIVE || ((features) & X86_CPU_FEATURE_PCLMUL)) +#define HAVE_AVX(features) (HAVE_AVX_NATIVE || ((features) & X86_CPU_FEATURE_AVX)) +#define HAVE_AVX2(features) (HAVE_AVX2_NATIVE || ((features) & X86_CPU_FEATURE_AVX2)) +#define HAVE_BMI2(features) (HAVE_BMI2_NATIVE || ((features) & X86_CPU_FEATURE_BMI2)) + +#if HAVE_DYNAMIC_X86_CPU_FEATURES +#define X86_CPU_FEATURES_KNOWN 0x80000000 +extern volatile u32 libdeflate_x86_cpu_features; + +void libdeflate_init_x86_cpu_features(void); + +static inline u32 get_x86_cpu_features(void) +{ + if (libdeflate_x86_cpu_features == 0) + libdeflate_init_x86_cpu_features(); + return libdeflate_x86_cpu_features; +} +#else /* HAVE_DYNAMIC_X86_CPU_FEATURES */ +static inline u32 get_x86_cpu_features(void) { return 0; } +#endif /* !HAVE_DYNAMIC_X86_CPU_FEATURES */ + +/* + * Prior to gcc 4.9 (r200349) and clang 3.8 (r239883), x86 intrinsics not + * available in the main target couldn't be used in 'target' attribute + * functions. Unfortunately clang has no feature test macro for this, so we + * have to check its version. + */ +#if HAVE_DYNAMIC_X86_CPU_FEATURES && \ + (GCC_PREREQ(4, 9) || CLANG_PREREQ(3, 8, 7030000) || defined(_MSC_VER)) +# define HAVE_TARGET_INTRINSICS 1 +#else +# define HAVE_TARGET_INTRINSICS 0 +#endif + +/* SSE2 */ +#if defined(__SSE2__) || \ + (defined(_MSC_VER) && \ + (defined(ARCH_X86_64) || (defined(_M_IX86_FP) && _M_IX86_FP >= 2))) +# define HAVE_SSE2_NATIVE 1 +#else +# define HAVE_SSE2_NATIVE 0 +#endif +#define HAVE_SSE2_INTRIN (HAVE_SSE2_NATIVE || HAVE_TARGET_INTRINSICS) + +/* PCLMUL */ +#if defined(__PCLMUL__) || (defined(_MSC_VER) && defined(__AVX2__)) +# define HAVE_PCLMUL_NATIVE 1 +#else +# define HAVE_PCLMUL_NATIVE 0 +#endif +#if HAVE_PCLMUL_NATIVE || (HAVE_TARGET_INTRINSICS && \ + (GCC_PREREQ(4, 4) || CLANG_PREREQ(3, 2, 0) || \ + defined(_MSC_VER))) +# define HAVE_PCLMUL_INTRIN 1 +#else +# define HAVE_PCLMUL_INTRIN 0 +#endif + +/* AVX */ +#ifdef __AVX__ +# define HAVE_AVX_NATIVE 1 +#else +# define HAVE_AVX_NATIVE 0 +#endif +#if HAVE_AVX_NATIVE || (HAVE_TARGET_INTRINSICS && \ + (GCC_PREREQ(4, 6) || CLANG_PREREQ(3, 0, 0) || \ + defined(_MSC_VER))) +# define HAVE_AVX_INTRIN 1 +#else +# define HAVE_AVX_INTRIN 0 +#endif + +/* AVX2 */ +#ifdef __AVX2__ +# define HAVE_AVX2_NATIVE 1 +#else +# define HAVE_AVX2_NATIVE 0 +#endif +#if HAVE_AVX2_NATIVE || (HAVE_TARGET_INTRINSICS && \ + (GCC_PREREQ(4, 7) || CLANG_PREREQ(3, 1, 0) || \ + defined(_MSC_VER))) +# define HAVE_AVX2_INTRIN 1 +#else +# define HAVE_AVX2_INTRIN 0 +#endif + +/* BMI2 */ +#if defined(__BMI2__) || (defined(_MSC_VER) && defined(__AVX2__)) +# define HAVE_BMI2_NATIVE 1 +#else +# define HAVE_BMI2_NATIVE 0 +#endif +#if HAVE_BMI2_NATIVE || (HAVE_TARGET_INTRINSICS && \ + (GCC_PREREQ(4, 7) || CLANG_PREREQ(3, 1, 0) || \ + defined(_MSC_VER))) +# define HAVE_BMI2_INTRIN 1 +#else +# define HAVE_BMI2_INTRIN 0 +#endif + +#endif /* ARCH_X86_32 || ARCH_X86_64 */ + +#endif /* LIB_X86_CPU_FEATURES_H */ + + +/* + * If the expression passed to SAFETY_CHECK() evaluates to false, then the + * decompression routine immediately returns LIBDEFLATE_BAD_DATA, indicating the + * compressed data is invalid. + * + * Theoretically, these checks could be disabled for specialized applications + * where all input to the decompressor will be trusted. + */ +#if 0 +# pragma message("UNSAFE DECOMPRESSION IS ENABLED. THIS MUST ONLY BE USED IF THE DECOMPRESSOR INPUT WILL ALWAYS BE TRUSTED!") +# define SAFETY_CHECK(expr) (void)(expr) +#else +# define SAFETY_CHECK(expr) if (unlikely(!(expr))) return LIBDEFLATE_BAD_DATA +#endif + +/***************************************************************************** + * Input bitstream * + *****************************************************************************/ + +/* + * The state of the "input bitstream" consists of the following variables: + * + * - in_next: a pointer to the next unread byte in the input buffer + * + * - in_end: a pointer to just past the end of the input buffer + * + * - bitbuf: a word-sized variable containing bits that have been read from + * the input buffer or from the implicit appended zero bytes + * + * - bitsleft: the number of bits in 'bitbuf' available to be consumed. + * After REFILL_BITS_BRANCHLESS(), 'bitbuf' can actually + * contain more bits than this. However, only the bits counted + * by 'bitsleft' can actually be consumed; the rest can only be + * used for preloading. + * + * As a micro-optimization, we allow bits 8 and higher of + * 'bitsleft' to contain garbage. When consuming the bits + * associated with a decode table entry, this allows us to do + * 'bitsleft -= entry' instead of 'bitsleft -= (u8)entry'. + * On some CPUs, this helps reduce instruction dependencies. + * This does have the disadvantage that 'bitsleft' sometimes + * needs to be cast to 'u8', such as when it's used as a shift + * amount in REFILL_BITS_BRANCHLESS(). But that one happens + * for free since most CPUs ignore high bits in shift amounts. + * + * - overread_count: the total number of implicit appended zero bytes that + * have been loaded into the bitbuffer, including any + * counted by 'bitsleft' and any already consumed + */ + +/* + * The type for the bitbuffer variable ('bitbuf' described above). For best + * performance, this should have size equal to a machine word. + * + * 64-bit platforms have a significant advantage: they get a bigger bitbuffer + * which they don't have to refill as often. + */ +typedef machine_word_t bitbuf_t; +#define BITBUF_NBITS (8 * (int)sizeof(bitbuf_t)) + +/* BITMASK(n) returns a bitmask of length 'n'. */ +#define BITMASK(n) (((bitbuf_t)1 << (n)) - 1) + +/* + * MAX_BITSLEFT is the maximum number of consumable bits, i.e. the maximum value + * of '(u8)bitsleft'. This is the size of the bitbuffer variable, minus 1 if + * the branchless refill method is being used (see REFILL_BITS_BRANCHLESS()). + */ +#define MAX_BITSLEFT \ + (UNALIGNED_ACCESS_IS_FAST ? BITBUF_NBITS - 1 : BITBUF_NBITS) + +/* + * CONSUMABLE_NBITS is the minimum number of bits that are guaranteed to be + * consumable (counted in 'bitsleft') immediately after refilling the bitbuffer. + * Since only whole bytes can be added to 'bitsleft', the worst case is + * 'MAX_BITSLEFT - 7': the smallest amount where another byte doesn't fit. + */ +#define CONSUMABLE_NBITS (MAX_BITSLEFT - 7) + +/* + * FASTLOOP_PRELOADABLE_NBITS is the minimum number of bits that are guaranteed + * to be preloadable immediately after REFILL_BITS_IN_FASTLOOP(). (It is *not* + * guaranteed after REFILL_BITS(), since REFILL_BITS() falls back to a + * byte-at-a-time refill method near the end of input.) This may exceed the + * number of consumable bits (counted by 'bitsleft'). Any bits not counted in + * 'bitsleft' can only be used for precomputation and cannot be consumed. + */ +#define FASTLOOP_PRELOADABLE_NBITS \ + (UNALIGNED_ACCESS_IS_FAST ? BITBUF_NBITS : CONSUMABLE_NBITS) + +/* + * PRELOAD_SLACK is the minimum number of bits that are guaranteed to be + * preloadable but not consumable, following REFILL_BITS_IN_FASTLOOP() and any + * subsequent consumptions. This is 1 bit if the branchless refill method is + * being used, and 0 bits otherwise. + */ +#define PRELOAD_SLACK MAX(0, FASTLOOP_PRELOADABLE_NBITS - MAX_BITSLEFT) + +/* + * CAN_CONSUME(n) is true if it's guaranteed that if the bitbuffer has just been + * refilled, then it's always possible to consume 'n' bits from it. 'n' should + * be a compile-time constant, to enable compile-time evaluation. + */ +#define CAN_CONSUME(n) (CONSUMABLE_NBITS >= (n)) + +/* + * CAN_CONSUME_AND_THEN_PRELOAD(consume_nbits, preload_nbits) is true if it's + * guaranteed that after REFILL_BITS_IN_FASTLOOP(), it's always possible to + * consume 'consume_nbits' bits, then preload 'preload_nbits' bits. The + * arguments should be compile-time constants to enable compile-time evaluation. + */ +#define CAN_CONSUME_AND_THEN_PRELOAD(consume_nbits, preload_nbits) \ + (CONSUMABLE_NBITS >= (consume_nbits) && \ + FASTLOOP_PRELOADABLE_NBITS >= (consume_nbits) + (preload_nbits)) + +/* + * REFILL_BITS_BRANCHLESS() branchlessly refills the bitbuffer variable by + * reading the next word from the input buffer and updating 'in_next' and + * 'bitsleft' based on how many bits were refilled -- counting whole bytes only. + * This is much faster than reading a byte at a time, at least if the CPU is + * little endian and supports fast unaligned memory accesses. + * + * The simplest way of branchlessly updating 'bitsleft' would be: + * + * bitsleft += (MAX_BITSLEFT - bitsleft) & ~7; + * + * To make it faster, we define MAX_BITSLEFT to be 'WORDBITS - 1' rather than + * WORDBITS, so that in binary it looks like 111111 or 11111. Then, we update + * 'bitsleft' by just setting the bits above the low 3 bits: + * + * bitsleft |= MAX_BITSLEFT & ~7; + * + * That compiles down to a single instruction like 'or $0x38, %rbp'. Using + * 'MAX_BITSLEFT == WORDBITS - 1' also has the advantage that refills can be + * done when 'bitsleft == MAX_BITSLEFT' without invoking undefined behavior. + * + * The simplest way of branchlessly updating 'in_next' would be: + * + * in_next += (MAX_BITSLEFT - bitsleft) >> 3; + * + * With 'MAX_BITSLEFT == WORDBITS - 1' we could use an XOR instead, though this + * isn't really better: + * + * in_next += (MAX_BITSLEFT ^ bitsleft) >> 3; + * + * An alternative which can be marginally better is the following: + * + * in_next += sizeof(bitbuf_t) - 1; + * in_next -= (bitsleft >> 3) & 0x7; + * + * It seems this would increase the number of CPU instructions from 3 (sub, shr, + * add) to 4 (add, shr, and, sub). However, if the CPU has a bitfield + * extraction instruction (e.g. arm's ubfx), it stays at 3, and is potentially + * more efficient because the length of the longest dependency chain decreases + * from 3 to 2. This alternative also has the advantage that it ignores the + * high bits in 'bitsleft', so it is compatible with the micro-optimization we + * use where we let the high bits of 'bitsleft' contain garbage. + */ +#define REFILL_BITS_BRANCHLESS() \ +do { \ + bitbuf |= get_unaligned_leword(in_next) << (u8)bitsleft; \ + in_next += sizeof(bitbuf_t) - 1; \ + in_next -= (bitsleft >> 3) & 0x7; \ + bitsleft |= MAX_BITSLEFT & ~7; \ +} while (0) + +/* + * REFILL_BITS() loads bits from the input buffer until the bitbuffer variable + * contains at least CONSUMABLE_NBITS consumable bits. + * + * This checks for the end of input, and it doesn't guarantee + * FASTLOOP_PRELOADABLE_NBITS, so it can't be used in the fastloop. + * + * If we would overread the input buffer, we just don't read anything, leaving + * the bits zeroed but marking them filled. This simplifies the decompressor + * because it removes the need to always be able to distinguish between real + * overreads and overreads caused only by the decompressor's own lookahead. + * + * We do still keep track of the number of bytes that have been overread, for + * two reasons. First, it allows us to determine the exact number of bytes that + * were consumed once the stream ends or an uncompressed block is reached. + * Second, it allows us to stop early if the overread amount gets so large (more + * than sizeof bitbuf) that it can only be caused by a real overread. (The + * second part is arguably unneeded, since libdeflate is buffer-based; given + * infinite zeroes, it will eventually either completely fill the output buffer + * or return an error. However, we do it to be slightly more friendly to the + * not-recommended use case of decompressing with an unknown output size.) + */ +#define REFILL_BITS() \ +do { \ + if (UNALIGNED_ACCESS_IS_FAST && \ + likely(in_end - in_next >= sizeof(bitbuf_t))) { \ + REFILL_BITS_BRANCHLESS(); \ + } else { \ + while ((u8)bitsleft < CONSUMABLE_NBITS) { \ + if (likely(in_next != in_end)) { \ + bitbuf |= (bitbuf_t)*in_next++ << \ + (u8)bitsleft; \ + } else { \ + overread_count++; \ + SAFETY_CHECK(overread_count <= \ + sizeof(bitbuf_t)); \ + } \ + bitsleft += 8; \ + } \ + } \ +} while (0) + +/* + * REFILL_BITS_IN_FASTLOOP() is like REFILL_BITS(), but it doesn't check for the + * end of the input. It can only be used in the fastloop. + */ +#define REFILL_BITS_IN_FASTLOOP() \ +do { \ + STATIC_ASSERT(UNALIGNED_ACCESS_IS_FAST || \ + FASTLOOP_PRELOADABLE_NBITS == CONSUMABLE_NBITS); \ + if (UNALIGNED_ACCESS_IS_FAST) { \ + REFILL_BITS_BRANCHLESS(); \ + } else { \ + while ((u8)bitsleft < CONSUMABLE_NBITS) { \ + bitbuf |= (bitbuf_t)*in_next++ << (u8)bitsleft; \ + bitsleft += 8; \ + } \ + } \ +} while (0) + +/* + * This is the worst-case maximum number of output bytes that are written to + * during each iteration of the fastloop. The worst case is 2 literals, then a + * match of length DEFLATE_MAX_MATCH_LEN. Additionally, some slack space must + * be included for the intentional overrun in the match copy implementation. + */ +#define FASTLOOP_MAX_BYTES_WRITTEN \ + (2 + DEFLATE_MAX_MATCH_LEN + (5 * WORDBYTES) - 1) + +/* + * This is the worst-case maximum number of input bytes that are read during + * each iteration of the fastloop. To get this value, we first compute the + * greatest number of bits that can be refilled during a loop iteration. The + * refill at the beginning can add at most MAX_BITSLEFT, and the amount that can + * be refilled later is no more than the maximum amount that can be consumed by + * 2 literals that don't need a subtable, then a match. We convert this value + * to bytes, rounding up; this gives the maximum number of bytes that 'in_next' + * can be advanced. Finally, we add sizeof(bitbuf_t) to account for + * REFILL_BITS_BRANCHLESS() reading a word past 'in_next'. + */ +#define FASTLOOP_MAX_BYTES_READ \ + (DIV_ROUND_UP(MAX_BITSLEFT + (2 * LITLEN_TABLEBITS) + \ + LENGTH_MAXBITS + OFFSET_MAXBITS, 8) + \ + sizeof(bitbuf_t)) + +/***************************************************************************** + * Huffman decoding * + *****************************************************************************/ + +/* + * The fastest way to decode Huffman-encoded data is basically to use a decode + * table that maps the next TABLEBITS bits of data to their symbol. Each entry + * decode_table[i] maps to the symbol whose codeword is a prefix of 'i'. A + * symbol with codeword length 'n' has '2**(TABLEBITS-n)' entries in the table. + * + * Ideally, TABLEBITS and the maximum codeword length would be the same; some + * compression formats are designed with this goal in mind. Unfortunately, in + * DEFLATE, the maximum litlen and offset codeword lengths are 15 bits, which is + * too large for a practical TABLEBITS. It's not *that* much larger, though, so + * the workaround is to use a single level of subtables. In the main table, + * entries for prefixes of codewords longer than TABLEBITS contain a "pointer" + * to the appropriate subtable along with the number of bits it is indexed with. + * + * The most efficient way to allocate subtables is to allocate them dynamically + * after the main table. The worst-case number of table entries needed, + * including subtables, is precomputable; see the ENOUGH constants below. + * + * A useful optimization is to store the codeword lengths in the decode table so + * that they don't have to be looked up by indexing a separate table that maps + * symbols to their codeword lengths. We basically do this; however, for the + * litlen and offset codes we also implement some DEFLATE-specific optimizations + * that build in the consideration of the "extra bits" and the + * literal/length/end-of-block division. For the exact decode table entry + * format we use, see the definitions of the *_decode_results[] arrays below. + */ + + +/* + * These are the TABLEBITS values we use for each of the DEFLATE Huffman codes, + * along with their corresponding ENOUGH values. + * + * For the precode, we use PRECODE_TABLEBITS == 7 since this is the maximum + * precode codeword length. This avoids ever needing subtables. + * + * For the litlen and offset codes, we cannot realistically avoid ever needing + * subtables, since litlen and offset codewords can be up to 15 bits. A higher + * TABLEBITS reduces the number of lookups that need a subtable, which increases + * performance; however, it increases memory usage and makes building the table + * take longer, which decreases performance. We choose values that work well in + * practice, making subtables rarely needed without making the tables too large. + * + * Our choice of OFFSET_TABLEBITS == 8 is a bit low; without any special + * considerations, 9 would fit the trade-off curve better. However, there is a + * performance benefit to using exactly 8 bits when it is a compile-time + * constant, as many CPUs can take the low byte more easily than the low 9 bits. + * + * zlib treats its equivalents of TABLEBITS as maximum values; whenever it + * builds a table, it caps the actual table_bits to the longest codeword. This + * makes sense in theory, as there's no need for the table to be any larger than + * needed to support the longest codeword. However, having the table bits be a + * compile-time constant is beneficial to the performance of the decode loop, so + * there is a trade-off. libdeflate currently uses the dynamic table_bits + * strategy for the litlen table only, due to its larger maximum size. + * PRECODE_TABLEBITS and OFFSET_TABLEBITS are smaller, so going dynamic there + * isn't as useful, and OFFSET_TABLEBITS=8 is useful as mentioned above. + * + * Each TABLEBITS value has a corresponding ENOUGH value that gives the + * worst-case maximum number of decode table entries, including the main table + * and all subtables. The ENOUGH value depends on three parameters: + * + * (1) the maximum number of symbols in the code (DEFLATE_NUM_*_SYMS) + * (2) the maximum number of main table bits (*_TABLEBITS) + * (3) the maximum allowed codeword length (DEFLATE_MAX_*_CODEWORD_LEN) + * + * The ENOUGH values were computed using the utility program 'enough' from zlib. + */ +#define PRECODE_TABLEBITS 7 +#define PRECODE_ENOUGH 128 /* enough 19 7 7 */ +#define LITLEN_TABLEBITS 11 +#define LITLEN_ENOUGH 2342 /* enough 288 11 15 */ +#define OFFSET_TABLEBITS 8 +#define OFFSET_ENOUGH 402 /* enough 32 8 15 */ + +/* + * make_decode_table_entry() creates a decode table entry for the given symbol + * by combining the static part 'decode_results[sym]' with the dynamic part + * 'len', which is the remaining codeword length (the codeword length for main + * table entries, or the codeword length minus TABLEBITS for subtable entries). + * + * In all cases, we add 'len' to each of the two low-order bytes to create the + * appropriately-formatted decode table entry. See the definitions of the + * *_decode_results[] arrays below, where the entry format is described. + */ +static forceinline u32 +make_decode_table_entry(const u32 decode_results[], u32 sym, u32 len) +{ + return decode_results[sym] + (len << 8) + len; +} + +/* + * Here is the format of our precode decode table entries. Bits not explicitly + * described contain zeroes: + * + * Bit 20-16: presym + * Bit 10-8: codeword length [not used] + * Bit 2-0: codeword length + * + * The precode decode table never has subtables, since we use + * PRECODE_TABLEBITS == DEFLATE_MAX_PRE_CODEWORD_LEN. + * + * precode_decode_results[] contains the static part of the entry for each + * symbol. make_decode_table_entry() produces the final entries. + */ +static const u32 precode_decode_results[] = { +#define ENTRY(presym) ((u32)presym << 16) + ENTRY(0) , ENTRY(1) , ENTRY(2) , ENTRY(3) , + ENTRY(4) , ENTRY(5) , ENTRY(6) , ENTRY(7) , + ENTRY(8) , ENTRY(9) , ENTRY(10) , ENTRY(11) , + ENTRY(12) , ENTRY(13) , ENTRY(14) , ENTRY(15) , + ENTRY(16) , ENTRY(17) , ENTRY(18) , +#undef ENTRY +}; + +/* Litlen and offset decode table entry flags */ + +/* Indicates a literal entry in the litlen decode table */ +#define HUFFDEC_LITERAL 0x80000000 + +/* Indicates that HUFFDEC_SUBTABLE_POINTER or HUFFDEC_END_OF_BLOCK is set */ +#define HUFFDEC_EXCEPTIONAL 0x00008000 + +/* Indicates a subtable pointer entry in the litlen or offset decode table */ +#define HUFFDEC_SUBTABLE_POINTER 0x00004000 + +/* Indicates an end-of-block entry in the litlen decode table */ +#define HUFFDEC_END_OF_BLOCK 0x00002000 + +/* Maximum number of bits that can be consumed by decoding a match length */ +#define LENGTH_MAXBITS (DEFLATE_MAX_LITLEN_CODEWORD_LEN + \ + DEFLATE_MAX_EXTRA_LENGTH_BITS) +#define LENGTH_MAXFASTBITS (LITLEN_TABLEBITS /* no subtable needed */ + \ + DEFLATE_MAX_EXTRA_LENGTH_BITS) + +/* + * Here is the format of our litlen decode table entries. Bits not explicitly + * described contain zeroes: + * + * Literals: + * Bit 31: 1 (HUFFDEC_LITERAL) + * Bit 23-16: literal value + * Bit 15: 0 (!HUFFDEC_EXCEPTIONAL) + * Bit 14: 0 (!HUFFDEC_SUBTABLE_POINTER) + * Bit 13: 0 (!HUFFDEC_END_OF_BLOCK) + * Bit 11-8: remaining codeword length [not used] + * Bit 3-0: remaining codeword length + * Lengths: + * Bit 31: 0 (!HUFFDEC_LITERAL) + * Bit 24-16: length base value + * Bit 15: 0 (!HUFFDEC_EXCEPTIONAL) + * Bit 14: 0 (!HUFFDEC_SUBTABLE_POINTER) + * Bit 13: 0 (!HUFFDEC_END_OF_BLOCK) + * Bit 11-8: remaining codeword length + * Bit 4-0: remaining codeword length + number of extra bits + * End of block: + * Bit 31: 0 (!HUFFDEC_LITERAL) + * Bit 15: 1 (HUFFDEC_EXCEPTIONAL) + * Bit 14: 0 (!HUFFDEC_SUBTABLE_POINTER) + * Bit 13: 1 (HUFFDEC_END_OF_BLOCK) + * Bit 11-8: remaining codeword length [not used] + * Bit 3-0: remaining codeword length + * Subtable pointer: + * Bit 31: 0 (!HUFFDEC_LITERAL) + * Bit 30-16: index of start of subtable + * Bit 15: 1 (HUFFDEC_EXCEPTIONAL) + * Bit 14: 1 (HUFFDEC_SUBTABLE_POINTER) + * Bit 13: 0 (!HUFFDEC_END_OF_BLOCK) + * Bit 11-8: number of subtable bits + * Bit 3-0: number of main table bits + * + * This format has several desirable properties: + * + * - The codeword length, length slot base, and number of extra length bits + * are all built in. This eliminates the need to separately look up this + * information by indexing separate arrays by symbol or length slot. + * + * - The HUFFDEC_* flags enable easily distinguishing between the different + * types of entries. The HUFFDEC_LITERAL flag enables a fast path for + * literals; the high bit is used for this, as some CPUs can test the + * high bit more easily than other bits. The HUFFDEC_EXCEPTIONAL flag + * makes it possible to detect the two unlikely cases (subtable pointer + * and end of block) in a single bit flag test. + * + * - The low byte is the number of bits that need to be removed from the + * bitstream; this makes this value easily accessible, and it enables the + * micro-optimization of doing 'bitsleft -= entry' instead of + * 'bitsleft -= (u8)entry'. It also includes the number of extra bits, + * so they don't need to be removed separately. + * + * - The flags in bits 15-13 are arranged to be 0 when the + * "remaining codeword length" in bits 11-8 is needed, making this value + * fairly easily accessible as well via a shift and downcast. + * + * - Similarly, bits 13-12 are 0 when the "subtable bits" in bits 11-8 are + * needed, making it possible to extract this value with '& 0x3F' rather + * than '& 0xF'. This value is only used as a shift amount, so this can + * save an 'and' instruction as the masking by 0x3F happens implicitly. + * + * litlen_decode_results[] contains the static part of the entry for each + * symbol. make_decode_table_entry() produces the final entries. + */ +static const u32 litlen_decode_results[] = { + + /* Literals */ +#define ENTRY(literal) (HUFFDEC_LITERAL | ((u32)literal << 16)) + ENTRY(0) , ENTRY(1) , ENTRY(2) , ENTRY(3) , + ENTRY(4) , ENTRY(5) , ENTRY(6) , ENTRY(7) , + ENTRY(8) , ENTRY(9) , ENTRY(10) , ENTRY(11) , + ENTRY(12) , ENTRY(13) , ENTRY(14) , ENTRY(15) , + ENTRY(16) , ENTRY(17) , ENTRY(18) , ENTRY(19) , + ENTRY(20) , ENTRY(21) , ENTRY(22) , ENTRY(23) , + ENTRY(24) , ENTRY(25) , ENTRY(26) , ENTRY(27) , + ENTRY(28) , ENTRY(29) , ENTRY(30) , ENTRY(31) , + ENTRY(32) , ENTRY(33) , ENTRY(34) , ENTRY(35) , + ENTRY(36) , ENTRY(37) , ENTRY(38) , ENTRY(39) , + ENTRY(40) , ENTRY(41) , ENTRY(42) , ENTRY(43) , + ENTRY(44) , ENTRY(45) , ENTRY(46) , ENTRY(47) , + ENTRY(48) , ENTRY(49) , ENTRY(50) , ENTRY(51) , + ENTRY(52) , ENTRY(53) , ENTRY(54) , ENTRY(55) , + ENTRY(56) , ENTRY(57) , ENTRY(58) , ENTRY(59) , + ENTRY(60) , ENTRY(61) , ENTRY(62) , ENTRY(63) , + ENTRY(64) , ENTRY(65) , ENTRY(66) , ENTRY(67) , + ENTRY(68) , ENTRY(69) , ENTRY(70) , ENTRY(71) , + ENTRY(72) , ENTRY(73) , ENTRY(74) , ENTRY(75) , + ENTRY(76) , ENTRY(77) , ENTRY(78) , ENTRY(79) , + ENTRY(80) , ENTRY(81) , ENTRY(82) , ENTRY(83) , + ENTRY(84) , ENTRY(85) , ENTRY(86) , ENTRY(87) , + ENTRY(88) , ENTRY(89) , ENTRY(90) , ENTRY(91) , + ENTRY(92) , ENTRY(93) , ENTRY(94) , ENTRY(95) , + ENTRY(96) , ENTRY(97) , ENTRY(98) , ENTRY(99) , + ENTRY(100) , ENTRY(101) , ENTRY(102) , ENTRY(103) , + ENTRY(104) , ENTRY(105) , ENTRY(106) , ENTRY(107) , + ENTRY(108) , ENTRY(109) , ENTRY(110) , ENTRY(111) , + ENTRY(112) , ENTRY(113) , ENTRY(114) , ENTRY(115) , + ENTRY(116) , ENTRY(117) , ENTRY(118) , ENTRY(119) , + ENTRY(120) , ENTRY(121) , ENTRY(122) , ENTRY(123) , + ENTRY(124) , ENTRY(125) , ENTRY(126) , ENTRY(127) , + ENTRY(128) , ENTRY(129) , ENTRY(130) , ENTRY(131) , + ENTRY(132) , ENTRY(133) , ENTRY(134) , ENTRY(135) , + ENTRY(136) , ENTRY(137) , ENTRY(138) , ENTRY(139) , + ENTRY(140) , ENTRY(141) , ENTRY(142) , ENTRY(143) , + ENTRY(144) , ENTRY(145) , ENTRY(146) , ENTRY(147) , + ENTRY(148) , ENTRY(149) , ENTRY(150) , ENTRY(151) , + ENTRY(152) , ENTRY(153) , ENTRY(154) , ENTRY(155) , + ENTRY(156) , ENTRY(157) , ENTRY(158) , ENTRY(159) , + ENTRY(160) , ENTRY(161) , ENTRY(162) , ENTRY(163) , + ENTRY(164) , ENTRY(165) , ENTRY(166) , ENTRY(167) , + ENTRY(168) , ENTRY(169) , ENTRY(170) , ENTRY(171) , + ENTRY(172) , ENTRY(173) , ENTRY(174) , ENTRY(175) , + ENTRY(176) , ENTRY(177) , ENTRY(178) , ENTRY(179) , + ENTRY(180) , ENTRY(181) , ENTRY(182) , ENTRY(183) , + ENTRY(184) , ENTRY(185) , ENTRY(186) , ENTRY(187) , + ENTRY(188) , ENTRY(189) , ENTRY(190) , ENTRY(191) , + ENTRY(192) , ENTRY(193) , ENTRY(194) , ENTRY(195) , + ENTRY(196) , ENTRY(197) , ENTRY(198) , ENTRY(199) , + ENTRY(200) , ENTRY(201) , ENTRY(202) , ENTRY(203) , + ENTRY(204) , ENTRY(205) , ENTRY(206) , ENTRY(207) , + ENTRY(208) , ENTRY(209) , ENTRY(210) , ENTRY(211) , + ENTRY(212) , ENTRY(213) , ENTRY(214) , ENTRY(215) , + ENTRY(216) , ENTRY(217) , ENTRY(218) , ENTRY(219) , + ENTRY(220) , ENTRY(221) , ENTRY(222) , ENTRY(223) , + ENTRY(224) , ENTRY(225) , ENTRY(226) , ENTRY(227) , + ENTRY(228) , ENTRY(229) , ENTRY(230) , ENTRY(231) , + ENTRY(232) , ENTRY(233) , ENTRY(234) , ENTRY(235) , + ENTRY(236) , ENTRY(237) , ENTRY(238) , ENTRY(239) , + ENTRY(240) , ENTRY(241) , ENTRY(242) , ENTRY(243) , + ENTRY(244) , ENTRY(245) , ENTRY(246) , ENTRY(247) , + ENTRY(248) , ENTRY(249) , ENTRY(250) , ENTRY(251) , + ENTRY(252) , ENTRY(253) , ENTRY(254) , ENTRY(255) , +#undef ENTRY + + /* End of block */ + HUFFDEC_EXCEPTIONAL | HUFFDEC_END_OF_BLOCK, + + /* Lengths */ +#define ENTRY(length_base, num_extra_bits) \ + (((u32)(length_base) << 16) | (num_extra_bits)) + ENTRY(3 , 0) , ENTRY(4 , 0) , ENTRY(5 , 0) , ENTRY(6 , 0), + ENTRY(7 , 0) , ENTRY(8 , 0) , ENTRY(9 , 0) , ENTRY(10 , 0), + ENTRY(11 , 1) , ENTRY(13 , 1) , ENTRY(15 , 1) , ENTRY(17 , 1), + ENTRY(19 , 2) , ENTRY(23 , 2) , ENTRY(27 , 2) , ENTRY(31 , 2), + ENTRY(35 , 3) , ENTRY(43 , 3) , ENTRY(51 , 3) , ENTRY(59 , 3), + ENTRY(67 , 4) , ENTRY(83 , 4) , ENTRY(99 , 4) , ENTRY(115, 4), + ENTRY(131, 5) , ENTRY(163, 5) , ENTRY(195, 5) , ENTRY(227, 5), + ENTRY(258, 0) , ENTRY(258, 0) , ENTRY(258, 0) , +#undef ENTRY +}; + +/* Maximum number of bits that can be consumed by decoding a match offset */ +#define OFFSET_MAXBITS (DEFLATE_MAX_OFFSET_CODEWORD_LEN + \ + DEFLATE_MAX_EXTRA_OFFSET_BITS) +#define OFFSET_MAXFASTBITS (OFFSET_TABLEBITS /* no subtable needed */ + \ + DEFLATE_MAX_EXTRA_OFFSET_BITS) + +/* + * Here is the format of our offset decode table entries. Bits not explicitly + * described contain zeroes: + * + * Offsets: + * Bit 31-16: offset base value + * Bit 15: 0 (!HUFFDEC_EXCEPTIONAL) + * Bit 14: 0 (!HUFFDEC_SUBTABLE_POINTER) + * Bit 11-8: remaining codeword length + * Bit 4-0: remaining codeword length + number of extra bits + * Subtable pointer: + * Bit 31-16: index of start of subtable + * Bit 15: 1 (HUFFDEC_EXCEPTIONAL) + * Bit 14: 1 (HUFFDEC_SUBTABLE_POINTER) + * Bit 11-8: number of subtable bits + * Bit 3-0: number of main table bits + * + * These work the same way as the length entries and subtable pointer entries in + * the litlen decode table; see litlen_decode_results[] above. + */ +static const u32 offset_decode_results[] = { +#define ENTRY(offset_base, num_extra_bits) \ + (((u32)(offset_base) << 16) | (num_extra_bits)) + ENTRY(1 , 0) , ENTRY(2 , 0) , ENTRY(3 , 0) , ENTRY(4 , 0) , + ENTRY(5 , 1) , ENTRY(7 , 1) , ENTRY(9 , 2) , ENTRY(13 , 2) , + ENTRY(17 , 3) , ENTRY(25 , 3) , ENTRY(33 , 4) , ENTRY(49 , 4) , + ENTRY(65 , 5) , ENTRY(97 , 5) , ENTRY(129 , 6) , ENTRY(193 , 6) , + ENTRY(257 , 7) , ENTRY(385 , 7) , ENTRY(513 , 8) , ENTRY(769 , 8) , + ENTRY(1025 , 9) , ENTRY(1537 , 9) , ENTRY(2049 , 10) , ENTRY(3073 , 10) , + ENTRY(4097 , 11) , ENTRY(6145 , 11) , ENTRY(8193 , 12) , ENTRY(12289 , 12) , + ENTRY(16385 , 13) , ENTRY(24577 , 13) , ENTRY(24577 , 13) , ENTRY(24577 , 13) , +#undef ENTRY +}; + +/* + * The main DEFLATE decompressor structure. Since libdeflate only supports + * full-buffer decompression, this structure doesn't store the entire + * decompression state, most of which is in stack variables. Instead, this + * struct just contains the decode tables and some temporary arrays used for + * building them, as these are too large to comfortably allocate on the stack. + * + * Storing the decode tables in the decompressor struct also allows the decode + * tables for the static codes to be reused whenever two static Huffman blocks + * are decoded without an intervening dynamic block, even across streams. + */ +struct libdeflate_decompressor { + + /* + * The arrays aren't all needed at the same time. 'precode_lens' and + * 'precode_decode_table' are unneeded after 'lens' has been filled. + * Furthermore, 'lens' need not be retained after building the litlen + * and offset decode tables. In fact, 'lens' can be in union with + * 'litlen_decode_table' provided that 'offset_decode_table' is separate + * and is built first. + */ + + union { + u8 precode_lens[DEFLATE_NUM_PRECODE_SYMS]; + + struct { + u8 lens[DEFLATE_NUM_LITLEN_SYMS + + DEFLATE_NUM_OFFSET_SYMS + + DEFLATE_MAX_LENS_OVERRUN]; + + u32 precode_decode_table[PRECODE_ENOUGH]; + } l; + + u32 litlen_decode_table[LITLEN_ENOUGH]; + } u; + + u32 offset_decode_table[OFFSET_ENOUGH]; + + /* used only during build_decode_table() */ + u16 sorted_syms[DEFLATE_MAX_NUM_SYMS]; + + bool static_codes_loaded; + unsigned litlen_tablebits; + + /* The free() function for this struct, chosen at allocation time */ + free_func_t free_func; +}; + +/* + * Build a table for fast decoding of symbols from a Huffman code. As input, + * this function takes the codeword length of each symbol which may be used in + * the code. As output, it produces a decode table for the canonical Huffman + * code described by the codeword lengths. The decode table is built with the + * assumption that it will be indexed with "bit-reversed" codewords, where the + * low-order bit is the first bit of the codeword. This format is used for all + * Huffman codes in DEFLATE. + * + * @decode_table + * The array in which the decode table will be generated. This array must + * have sufficient length; see the definition of the ENOUGH numbers. + * @lens + * An array which provides, for each symbol, the length of the + * corresponding codeword in bits, or 0 if the symbol is unused. This may + * alias @decode_table, since nothing is written to @decode_table until all + * @lens have been consumed. All codeword lengths are assumed to be <= + * @max_codeword_len but are otherwise considered untrusted. If they do + * not form a valid Huffman code, then the decode table is not built and + * %false is returned. + * @num_syms + * The number of symbols in the code, including all unused symbols. + * @decode_results + * An array which gives the incomplete decode result for each symbol. The + * needed values in this array will be combined with codeword lengths to + * make the final decode table entries using make_decode_table_entry(). + * @table_bits + * The log base-2 of the number of main table entries to use. + * If @table_bits_ret != NULL, then @table_bits is treated as a maximum + * value and it will be decreased if a smaller table would be sufficient. + * @max_codeword_len + * The maximum allowed codeword length for this Huffman code. + * Must be <= DEFLATE_MAX_CODEWORD_LEN. + * @sorted_syms + * A temporary array of length @num_syms. + * @table_bits_ret + * If non-NULL, then the dynamic table_bits is enabled, and the actual + * table_bits value will be returned here. + * + * Returns %true if successful; %false if the codeword lengths do not form a + * valid Huffman code. + */ +static bool +build_decode_table(u32 decode_table[], + const u8 lens[], + const unsigned num_syms, + const u32 decode_results[], + unsigned table_bits, + unsigned max_codeword_len, + u16 *sorted_syms, + unsigned *table_bits_ret) +{ + unsigned len_counts[DEFLATE_MAX_CODEWORD_LEN + 1]; + unsigned offsets[DEFLATE_MAX_CODEWORD_LEN + 1]; + unsigned sym; /* current symbol */ + unsigned codeword; /* current codeword, bit-reversed */ + unsigned len; /* current codeword length in bits */ + unsigned count; /* num codewords remaining with this length */ + u32 codespace_used; /* codespace used out of '2^max_codeword_len' */ + unsigned cur_table_end; /* end index of current table */ + unsigned subtable_prefix; /* codeword prefix of current subtable */ + unsigned subtable_start; /* start index of current subtable */ + unsigned subtable_bits; /* log2 of current subtable length */ + + /* Count how many codewords have each length, including 0. */ + for (len = 0; len <= max_codeword_len; len++) + len_counts[len] = 0; + for (sym = 0; sym < num_syms; sym++) + len_counts[lens[sym]]++; + + /* + * Determine the actual maximum codeword length that was used, and + * decrease table_bits to it if allowed. + */ + while (max_codeword_len > 1 && len_counts[max_codeword_len] == 0) + max_codeword_len--; + if (table_bits_ret != NULL) { + table_bits = MIN(table_bits, max_codeword_len); + *table_bits_ret = table_bits; + } + + /* + * Sort the symbols primarily by increasing codeword length and + * secondarily by increasing symbol value; or equivalently by their + * codewords in lexicographic order, since a canonical code is assumed. + * + * For efficiency, also compute 'codespace_used' in the same pass over + * 'len_counts[]' used to build 'offsets[]' for sorting. + */ + + /* Ensure that 'codespace_used' cannot overflow. */ + STATIC_ASSERT(sizeof(codespace_used) == 4); + STATIC_ASSERT(UINT32_MAX / (1U << (DEFLATE_MAX_CODEWORD_LEN - 1)) >= + DEFLATE_MAX_NUM_SYMS); + + offsets[0] = 0; + offsets[1] = len_counts[0]; + codespace_used = 0; + for (len = 1; len < max_codeword_len; len++) { + offsets[len + 1] = offsets[len] + len_counts[len]; + codespace_used = (codespace_used << 1) + len_counts[len]; + } + codespace_used = (codespace_used << 1) + len_counts[len]; + + for (sym = 0; sym < num_syms; sym++) + sorted_syms[offsets[lens[sym]]++] = sym; + + sorted_syms += offsets[0]; /* Skip unused symbols */ + + /* lens[] is done being used, so we can write to decode_table[] now. */ + + /* + * Check whether the lengths form a complete code (exactly fills the + * codespace), an incomplete code (doesn't fill the codespace), or an + * overfull code (overflows the codespace). A codeword of length 'n' + * uses proportion '1/(2^n)' of the codespace. An overfull code is + * nonsensical, so is considered invalid. An incomplete code is + * considered valid only in two specific cases; see below. + */ + + /* overfull code? */ + if (unlikely(codespace_used > (1U << max_codeword_len))) + return false; + + /* incomplete code? */ + if (unlikely(codespace_used < (1U << max_codeword_len))) { + u32 entry; + unsigned i; + + if (codespace_used == 0) { + /* + * An empty code is allowed. This can happen for the + * offset code in DEFLATE, since a dynamic Huffman block + * need not contain any matches. + */ + + /* sym=0, len=1 (arbitrary) */ + entry = make_decode_table_entry(decode_results, 0, 1); + } else { + /* + * Allow codes with a single used symbol, with codeword + * length 1. The DEFLATE RFC is unclear regarding this + * case. What zlib's decompressor does is permit this + * for the litlen and offset codes and assume the + * codeword is '0' rather than '1'. We do the same + * except we allow this for precodes too, since there's + * no convincing reason to treat the codes differently. + * We also assign both codewords '0' and '1' to the + * symbol to avoid having to handle '1' specially. + */ + if (codespace_used != (1U << (max_codeword_len - 1)) || + len_counts[1] != 1) + return false; + entry = make_decode_table_entry(decode_results, + *sorted_syms, 1); + } + /* + * Note: the decode table still must be fully initialized, in + * case the stream is malformed and contains bits from the part + * of the codespace the incomplete code doesn't use. + */ + for (i = 0; i < (1U << table_bits); i++) + decode_table[i] = entry; + return true; + } + + /* + * The lengths form a complete code. Now, enumerate the codewords in + * lexicographic order and fill the decode table entries for each one. + * + * First, process all codewords with len <= table_bits. Each one gets + * '2^(table_bits-len)' direct entries in the table. + * + * Since DEFLATE uses bit-reversed codewords, these entries aren't + * consecutive but rather are spaced '2^len' entries apart. This makes + * filling them naively somewhat awkward and inefficient, since strided + * stores are less cache-friendly and preclude the use of word or + * vector-at-a-time stores to fill multiple entries per instruction. + * + * To optimize this, we incrementally double the table size. When + * processing codewords with length 'len', the table is treated as + * having only '2^len' entries, so each codeword uses just one entry. + * Then, each time 'len' is incremented, the table size is doubled and + * the first half is copied to the second half. This significantly + * improves performance over naively doing strided stores. + * + * Note that some entries copied for each table doubling may not have + * been initialized yet, but it doesn't matter since they're guaranteed + * to be initialized later (because the Huffman code is complete). + */ + codeword = 0; + len = 1; + while ((count = len_counts[len]) == 0) + len++; + cur_table_end = 1U << len; + while (len <= table_bits) { + /* Process all 'count' codewords with length 'len' bits. */ + do { + unsigned bit; + + /* Fill the first entry for the current codeword. */ + decode_table[codeword] = + make_decode_table_entry(decode_results, + *sorted_syms++, len); + + if (codeword == cur_table_end - 1) { + /* Last codeword (all 1's) */ + for (; len < table_bits; len++) { + memcpy(&decode_table[cur_table_end], + decode_table, + cur_table_end * + sizeof(decode_table[0])); + cur_table_end <<= 1; + } + return true; + } + /* + * To advance to the lexicographically next codeword in + * the canonical code, the codeword must be incremented, + * then 0's must be appended to the codeword as needed + * to match the next codeword's length. + * + * Since the codeword is bit-reversed, appending 0's is + * a no-op. However, incrementing it is nontrivial. To + * do so efficiently, use the 'bsr' instruction to find + * the last (highest order) 0 bit in the codeword, set + * it, and clear any later (higher order) 1 bits. But + * 'bsr' actually finds the highest order 1 bit, so to + * use it first flip all bits in the codeword by XOR'ing + * it with (1U << len) - 1 == cur_table_end - 1. + */ + bit = 1U << bsr32(codeword ^ (cur_table_end - 1)); + codeword &= bit - 1; + codeword |= bit; + } while (--count); + + /* Advance to the next codeword length. */ + do { + if (++len <= table_bits) { + memcpy(&decode_table[cur_table_end], + decode_table, + cur_table_end * sizeof(decode_table[0])); + cur_table_end <<= 1; + } + } while ((count = len_counts[len]) == 0); + } + + /* Process codewords with len > table_bits. These require subtables. */ + cur_table_end = 1U << table_bits; + subtable_prefix = -1; + subtable_start = 0; + for (;;) { + u32 entry; + unsigned i; + unsigned stride; + unsigned bit; + + /* + * Start a new subtable if the first 'table_bits' bits of the + * codeword don't match the prefix of the current subtable. + */ + if ((codeword & ((1U << table_bits) - 1)) != subtable_prefix) { + subtable_prefix = (codeword & ((1U << table_bits) - 1)); + subtable_start = cur_table_end; + /* + * Calculate the subtable length. If the codeword has + * length 'table_bits + n', then the subtable needs + * '2^n' entries. But it may need more; if fewer than + * '2^n' codewords of length 'table_bits + n' remain, + * then the length will need to be incremented to bring + * in longer codewords until the subtable can be + * completely filled. Note that because the Huffman + * code is complete, it will always be possible to fill + * the subtable eventually. + */ + subtable_bits = len - table_bits; + codespace_used = count; + while (codespace_used < (1U << subtable_bits)) { + subtable_bits++; + codespace_used = (codespace_used << 1) + + len_counts[table_bits + subtable_bits]; + } + cur_table_end = subtable_start + (1U << subtable_bits); + + /* + * Create the entry that points from the main table to + * the subtable. + */ + decode_table[subtable_prefix] = + ((u32)subtable_start << 16) | + HUFFDEC_EXCEPTIONAL | + HUFFDEC_SUBTABLE_POINTER | + (subtable_bits << 8) | table_bits; + } + + /* Fill the subtable entries for the current codeword. */ + entry = make_decode_table_entry(decode_results, *sorted_syms++, + len - table_bits); + i = subtable_start + (codeword >> table_bits); + stride = 1U << (len - table_bits); + do { + decode_table[i] = entry; + i += stride; + } while (i < cur_table_end); + + /* Advance to the next codeword. */ + if (codeword == (1U << len) - 1) /* last codeword (all 1's)? */ + return true; + bit = 1U << bsr32(codeword ^ ((1U << len) - 1)); + codeword &= bit - 1; + codeword |= bit; + count--; + while (count == 0) + count = len_counts[++len]; + } +} + +/* Build the decode table for the precode. */ +static bool +build_precode_decode_table(struct libdeflate_decompressor *d) +{ + /* When you change TABLEBITS, you must change ENOUGH, and vice versa! */ + STATIC_ASSERT(PRECODE_TABLEBITS == 7 && PRECODE_ENOUGH == 128); + + STATIC_ASSERT(ARRAY_LEN(precode_decode_results) == + DEFLATE_NUM_PRECODE_SYMS); + + return build_decode_table(d->u.l.precode_decode_table, + d->u.precode_lens, + DEFLATE_NUM_PRECODE_SYMS, + precode_decode_results, + PRECODE_TABLEBITS, + DEFLATE_MAX_PRE_CODEWORD_LEN, + d->sorted_syms, + NULL); +} + +/* Build the decode table for the literal/length code. */ +static bool +build_litlen_decode_table(struct libdeflate_decompressor *d, + unsigned num_litlen_syms, unsigned num_offset_syms) +{ + /* When you change TABLEBITS, you must change ENOUGH, and vice versa! */ + STATIC_ASSERT(LITLEN_TABLEBITS == 11 && LITLEN_ENOUGH == 2342); + + STATIC_ASSERT(ARRAY_LEN(litlen_decode_results) == + DEFLATE_NUM_LITLEN_SYMS); + + return build_decode_table(d->u.litlen_decode_table, + d->u.l.lens, + num_litlen_syms, + litlen_decode_results, + LITLEN_TABLEBITS, + DEFLATE_MAX_LITLEN_CODEWORD_LEN, + d->sorted_syms, + &d->litlen_tablebits); +} + +/* Build the decode table for the offset code. */ +static bool +build_offset_decode_table(struct libdeflate_decompressor *d, + unsigned num_litlen_syms, unsigned num_offset_syms) +{ + /* When you change TABLEBITS, you must change ENOUGH, and vice versa! */ + STATIC_ASSERT(OFFSET_TABLEBITS == 8 && OFFSET_ENOUGH == 402); + + STATIC_ASSERT(ARRAY_LEN(offset_decode_results) == + DEFLATE_NUM_OFFSET_SYMS); + + return build_decode_table(d->offset_decode_table, + d->u.l.lens + num_litlen_syms, + num_offset_syms, + offset_decode_results, + OFFSET_TABLEBITS, + DEFLATE_MAX_OFFSET_CODEWORD_LEN, + d->sorted_syms, + NULL); +} + +/***************************************************************************** + * Main decompression routine + *****************************************************************************/ + +typedef enum libdeflate_result (*decompress_func_t) + (struct libdeflate_decompressor * restrict d, + const void * restrict in, size_t in_nbytes, + void * restrict out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, size_t *actual_out_nbytes_ret); + +#define FUNCNAME deflate_decompress_default +#undef ATTRIBUTES +#undef EXTRACT_VARBITS +#undef EXTRACT_VARBITS8 +/* + * decompress_template.h + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +/* + * This is the actual DEFLATE decompression routine, lifted out of + * deflate_decompress.c so that it can be compiled multiple times with different + * target instruction sets. + */ + +#ifndef ATTRIBUTES +# define ATTRIBUTES +#endif +#ifndef EXTRACT_VARBITS +# define EXTRACT_VARBITS(word, count) ((word) & BITMASK(count)) +#endif +#ifndef EXTRACT_VARBITS8 +# define EXTRACT_VARBITS8(word, count) ((word) & BITMASK((u8)(count))) +#endif + +static enum libdeflate_result ATTRIBUTES MAYBE_UNUSED +FUNCNAME(struct libdeflate_decompressor * restrict d, + const void * restrict in, size_t in_nbytes, + void * restrict out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, size_t *actual_out_nbytes_ret) +{ + u8 *out_next = out; + u8 * const out_end = out_next + out_nbytes_avail; + u8 * const out_fastloop_end = + out_end - MIN(out_nbytes_avail, FASTLOOP_MAX_BYTES_WRITTEN); + + /* Input bitstream state; see deflate_decompress.c for documentation */ + const u8 *in_next = in; + const u8 * const in_end = in_next + in_nbytes; + const u8 * const in_fastloop_end = + in_end - MIN(in_nbytes, FASTLOOP_MAX_BYTES_READ); + bitbuf_t bitbuf = 0; + bitbuf_t saved_bitbuf; + u32 bitsleft = 0; + size_t overread_count = 0; + + bool is_final_block; + unsigned block_type; + unsigned num_litlen_syms; + unsigned num_offset_syms; + bitbuf_t litlen_tablemask; + u32 entry; + +next_block: + /* Starting to read the next block */ + ; + + STATIC_ASSERT(CAN_CONSUME(1 + 2 + 5 + 5 + 4 + 3)); + REFILL_BITS(); + + /* BFINAL: 1 bit */ + is_final_block = bitbuf & BITMASK(1); + + /* BTYPE: 2 bits */ + block_type = (bitbuf >> 1) & BITMASK(2); + + if (block_type == DEFLATE_BLOCKTYPE_DYNAMIC_HUFFMAN) { + + /* Dynamic Huffman block */ + + /* The order in which precode lengths are stored */ + static const u8 deflate_precode_lens_permutation[DEFLATE_NUM_PRECODE_SYMS] = { + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 + }; + + unsigned num_explicit_precode_lens; + unsigned i; + + /* Read the codeword length counts. */ + + STATIC_ASSERT(DEFLATE_NUM_LITLEN_SYMS == 257 + BITMASK(5)); + num_litlen_syms = 257 + ((bitbuf >> 3) & BITMASK(5)); + + STATIC_ASSERT(DEFLATE_NUM_OFFSET_SYMS == 1 + BITMASK(5)); + num_offset_syms = 1 + ((bitbuf >> 8) & BITMASK(5)); + + STATIC_ASSERT(DEFLATE_NUM_PRECODE_SYMS == 4 + BITMASK(4)); + num_explicit_precode_lens = 4 + ((bitbuf >> 13) & BITMASK(4)); + + d->static_codes_loaded = false; + + /* + * Read the precode codeword lengths. + * + * A 64-bit bitbuffer is just one bit too small to hold the + * maximum number of precode lens, so to minimize branches we + * merge one len with the previous fields. + */ + STATIC_ASSERT(DEFLATE_MAX_PRE_CODEWORD_LEN == (1 << 3) - 1); + if (CAN_CONSUME(3 * (DEFLATE_NUM_PRECODE_SYMS - 1))) { + d->u.precode_lens[deflate_precode_lens_permutation[0]] = + (bitbuf >> 17) & BITMASK(3); + bitbuf >>= 20; + bitsleft -= 20; + REFILL_BITS(); + i = 1; + do { + d->u.precode_lens[deflate_precode_lens_permutation[i]] = + bitbuf & BITMASK(3); + bitbuf >>= 3; + bitsleft -= 3; + } while (++i < num_explicit_precode_lens); + } else { + bitbuf >>= 17; + bitsleft -= 17; + i = 0; + do { + if ((u8)bitsleft < 3) + REFILL_BITS(); + d->u.precode_lens[deflate_precode_lens_permutation[i]] = + bitbuf & BITMASK(3); + bitbuf >>= 3; + bitsleft -= 3; + } while (++i < num_explicit_precode_lens); + } + for (; i < DEFLATE_NUM_PRECODE_SYMS; i++) + d->u.precode_lens[deflate_precode_lens_permutation[i]] = 0; + + /* Build the decode table for the precode. */ + SAFETY_CHECK(build_precode_decode_table(d)); + + /* Decode the litlen and offset codeword lengths. */ + i = 0; + do { + unsigned presym; + u8 rep_val; + unsigned rep_count; + + if ((u8)bitsleft < DEFLATE_MAX_PRE_CODEWORD_LEN + 7) + REFILL_BITS(); + + /* + * The code below assumes that the precode decode table + * doesn't have any subtables. + */ + STATIC_ASSERT(PRECODE_TABLEBITS == DEFLATE_MAX_PRE_CODEWORD_LEN); + + /* Decode the next precode symbol. */ + entry = d->u.l.precode_decode_table[ + bitbuf & BITMASK(DEFLATE_MAX_PRE_CODEWORD_LEN)]; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + presym = entry >> 16; + + if (presym < 16) { + /* Explicit codeword length */ + d->u.l.lens[i++] = presym; + continue; + } + + /* Run-length encoded codeword lengths */ + + /* + * Note: we don't need to immediately verify that the + * repeat count doesn't overflow the number of elements, + * since we've sized the lens array to have enough extra + * space to allow for the worst-case overrun (138 zeroes + * when only 1 length was remaining). + * + * In the case of the small repeat counts (presyms 16 + * and 17), it is fastest to always write the maximum + * number of entries. That gets rid of branches that + * would otherwise be required. + * + * It is not just because of the numerical order that + * our checks go in the order 'presym < 16', 'presym == + * 16', and 'presym == 17'. For typical data this is + * ordered from most frequent to least frequent case. + */ + STATIC_ASSERT(DEFLATE_MAX_LENS_OVERRUN == 138 - 1); + + if (presym == 16) { + /* Repeat the previous length 3 - 6 times. */ + SAFETY_CHECK(i != 0); + rep_val = d->u.l.lens[i - 1]; + STATIC_ASSERT(3 + BITMASK(2) == 6); + rep_count = 3 + (bitbuf & BITMASK(2)); + bitbuf >>= 2; + bitsleft -= 2; + d->u.l.lens[i + 0] = rep_val; + d->u.l.lens[i + 1] = rep_val; + d->u.l.lens[i + 2] = rep_val; + d->u.l.lens[i + 3] = rep_val; + d->u.l.lens[i + 4] = rep_val; + d->u.l.lens[i + 5] = rep_val; + i += rep_count; + } else if (presym == 17) { + /* Repeat zero 3 - 10 times. */ + STATIC_ASSERT(3 + BITMASK(3) == 10); + rep_count = 3 + (bitbuf & BITMASK(3)); + bitbuf >>= 3; + bitsleft -= 3; + d->u.l.lens[i + 0] = 0; + d->u.l.lens[i + 1] = 0; + d->u.l.lens[i + 2] = 0; + d->u.l.lens[i + 3] = 0; + d->u.l.lens[i + 4] = 0; + d->u.l.lens[i + 5] = 0; + d->u.l.lens[i + 6] = 0; + d->u.l.lens[i + 7] = 0; + d->u.l.lens[i + 8] = 0; + d->u.l.lens[i + 9] = 0; + i += rep_count; + } else { + /* Repeat zero 11 - 138 times. */ + STATIC_ASSERT(11 + BITMASK(7) == 138); + rep_count = 11 + (bitbuf & BITMASK(7)); + bitbuf >>= 7; + bitsleft -= 7; + memset(&d->u.l.lens[i], 0, + rep_count * sizeof(d->u.l.lens[i])); + i += rep_count; + } + } while (i < num_litlen_syms + num_offset_syms); + + /* Unnecessary, but check this for consistency with zlib. */ + SAFETY_CHECK(i == num_litlen_syms + num_offset_syms); + + } else if (block_type == DEFLATE_BLOCKTYPE_UNCOMPRESSED) { + u16 len, nlen; + + /* + * Uncompressed block: copy 'len' bytes literally from the input + * buffer to the output buffer. + */ + + bitsleft -= 3; /* for BTYPE and BFINAL */ + + /* + * Align the bitstream to the next byte boundary. This means + * the next byte boundary as if we were reading a byte at a + * time. Therefore, we have to rewind 'in_next' by any bytes + * that have been refilled but not actually consumed yet (not + * counting overread bytes, which don't increment 'in_next'). + */ + bitsleft = (u8)bitsleft; + SAFETY_CHECK(overread_count <= (bitsleft >> 3)); + in_next -= (bitsleft >> 3) - overread_count; + overread_count = 0; + bitbuf = 0; + bitsleft = 0; + + SAFETY_CHECK(in_end - in_next >= 4); + len = get_unaligned_le16(in_next); + nlen = get_unaligned_le16(in_next + 2); + in_next += 4; + + SAFETY_CHECK(len == (u16)~nlen); + if (unlikely(len > out_end - out_next)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + SAFETY_CHECK(len <= in_end - in_next); + + memcpy(out_next, in_next, len); + in_next += len; + out_next += len; + + goto block_done; + + } else { + unsigned i; + + SAFETY_CHECK(block_type == DEFLATE_BLOCKTYPE_STATIC_HUFFMAN); + + /* + * Static Huffman block: build the decode tables for the static + * codes. Skip doing so if the tables are already set up from + * an earlier static block; this speeds up decompression of + * degenerate input of many empty or very short static blocks. + * + * Afterwards, the remainder is the same as decompressing a + * dynamic Huffman block. + */ + + bitbuf >>= 3; /* for BTYPE and BFINAL */ + bitsleft -= 3; + + if (d->static_codes_loaded) + goto have_decode_tables; + + d->static_codes_loaded = true; + + STATIC_ASSERT(DEFLATE_NUM_LITLEN_SYMS == 288); + STATIC_ASSERT(DEFLATE_NUM_OFFSET_SYMS == 32); + + for (i = 0; i < 144; i++) + d->u.l.lens[i] = 8; + for (; i < 256; i++) + d->u.l.lens[i] = 9; + for (; i < 280; i++) + d->u.l.lens[i] = 7; + for (; i < 288; i++) + d->u.l.lens[i] = 8; + + for (; i < 288 + 32; i++) + d->u.l.lens[i] = 5; + + num_litlen_syms = 288; + num_offset_syms = 32; + } + + /* Decompressing a Huffman block (either dynamic or static) */ + + SAFETY_CHECK(build_offset_decode_table(d, num_litlen_syms, num_offset_syms)); + SAFETY_CHECK(build_litlen_decode_table(d, num_litlen_syms, num_offset_syms)); +have_decode_tables: + litlen_tablemask = BITMASK(d->litlen_tablebits); + + /* + * This is the "fastloop" for decoding literals and matches. It does + * bounds checks on in_next and out_next in the loop conditions so that + * additional bounds checks aren't needed inside the loop body. + * + * To reduce latency, the bitbuffer is refilled and the next litlen + * decode table entry is preloaded before each loop iteration. + */ + if (in_next >= in_fastloop_end || out_next >= out_fastloop_end) + goto generic_loop; + REFILL_BITS_IN_FASTLOOP(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + do { + u32 length, offset, lit; + const u8 *src; + u8 *dst; + + /* + * Consume the bits for the litlen decode table entry. Save the + * original bitbuf for later, in case the extra match length + * bits need to be extracted from it. + */ + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + + /* + * Begin by checking for a "fast" literal, i.e. a literal that + * doesn't need a subtable. + */ + if (entry & HUFFDEC_LITERAL) { + /* + * On 64-bit platforms, we decode up to 2 extra fast + * literals in addition to the primary item, as this + * increases performance and still leaves enough bits + * remaining for what follows. We could actually do 3, + * assuming LITLEN_TABLEBITS=11, but that actually + * decreases performance slightly (perhaps by messing + * with the branch prediction of the conditional refill + * that happens later while decoding the match offset). + * + * Note: the definitions of FASTLOOP_MAX_BYTES_WRITTEN + * and FASTLOOP_MAX_BYTES_READ need to be updated if the + * number of extra literals decoded here is changed. + */ + if (/* enough bits for 2 fast literals + length + offset preload? */ + CAN_CONSUME_AND_THEN_PRELOAD(2 * LITLEN_TABLEBITS + + LENGTH_MAXBITS, + OFFSET_TABLEBITS) && + /* enough bits for 2 fast literals + slow literal + litlen preload? */ + CAN_CONSUME_AND_THEN_PRELOAD(2 * LITLEN_TABLEBITS + + DEFLATE_MAX_LITLEN_CODEWORD_LEN, + LITLEN_TABLEBITS)) { + /* 1st extra fast literal */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + *out_next++ = lit; + if (entry & HUFFDEC_LITERAL) { + /* 2nd extra fast literal */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + *out_next++ = lit; + if (entry & HUFFDEC_LITERAL) { + /* + * Another fast literal, but + * this one is in lieu of the + * primary item, so it doesn't + * count as one of the extras. + */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + } + } else { + /* + * Decode a literal. While doing so, preload + * the next litlen decode table entry and refill + * the bitbuffer. To reduce latency, we've + * arranged for there to be enough "preloadable" + * bits remaining to do the table preload + * independently of the refill. + */ + STATIC_ASSERT(CAN_CONSUME_AND_THEN_PRELOAD( + LITLEN_TABLEBITS, LITLEN_TABLEBITS)); + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + } + + /* + * It's not a literal entry, so it can be a length entry, a + * subtable pointer entry, or an end-of-block entry. Detect the + * two unlikely cases by testing the HUFFDEC_EXCEPTIONAL flag. + */ + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Subtable pointer or end-of-block entry */ + + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + + /* + * A subtable is required. Load and consume the + * subtable entry. The subtable entry can be of any + * type: literal, length, or end-of-block. + */ + entry = d->u.litlen_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + + /* + * 32-bit platforms that use the byte-at-a-time refill + * method have to do a refill here for there to always + * be enough bits to decode a literal that requires a + * subtable, then preload the next litlen decode table + * entry; or to decode a match length that requires a + * subtable, then preload the offset decode table entry. + */ + if (!CAN_CONSUME_AND_THEN_PRELOAD(DEFLATE_MAX_LITLEN_CODEWORD_LEN, + LITLEN_TABLEBITS) || + !CAN_CONSUME_AND_THEN_PRELOAD(LENGTH_MAXBITS, + OFFSET_TABLEBITS)) + REFILL_BITS_IN_FASTLOOP(); + if (entry & HUFFDEC_LITERAL) { + /* Decode a literal that required a subtable. */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + /* Else, it's a length that required a subtable. */ + } + + /* + * Decode the match length: the length base value associated + * with the litlen symbol (which we extract from the decode + * table entry), plus the extra length bits. We don't need to + * consume the extra length bits here, as they were included in + * the bits consumed by the entry earlier. We also don't need + * to check for too-long matches here, as this is inside the + * fastloop where it's already been verified that the output + * buffer has enough space remaining to copy a max-length match. + */ + length = entry >> 16; + length += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + + /* + * Decode the match offset. There are enough "preloadable" bits + * remaining to preload the offset decode table entry, but a + * refill might be needed before consuming it. + */ + STATIC_ASSERT(CAN_CONSUME_AND_THEN_PRELOAD(LENGTH_MAXFASTBITS, + OFFSET_TABLEBITS)); + entry = d->offset_decode_table[bitbuf & BITMASK(OFFSET_TABLEBITS)]; + if (CAN_CONSUME_AND_THEN_PRELOAD(OFFSET_MAXBITS, + LITLEN_TABLEBITS)) { + /* + * Decoding a match offset on a 64-bit platform. We may + * need to refill once, but then we can decode the whole + * offset and preload the next litlen table entry. + */ + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Offset codeword requires a subtable */ + if (unlikely((u8)bitsleft < OFFSET_MAXBITS + + LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + } else if (unlikely((u8)bitsleft < OFFSET_MAXFASTBITS + + LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + } else { + /* Decoding a match offset on a 32-bit platform */ + REFILL_BITS_IN_FASTLOOP(); + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Offset codeword requires a subtable */ + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + REFILL_BITS_IN_FASTLOOP(); + /* No further refill needed before extra bits */ + STATIC_ASSERT(CAN_CONSUME( + OFFSET_MAXBITS - OFFSET_TABLEBITS)); + } else { + /* No refill needed before extra bits */ + STATIC_ASSERT(CAN_CONSUME(OFFSET_MAXFASTBITS)); + } + } + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + offset = entry >> 16; + offset += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + + /* Validate the match offset; needed even in the fastloop. */ + SAFETY_CHECK(offset <= out_next - (const u8 *)out); + src = out_next - offset; + dst = out_next; + out_next += length; + + /* + * Before starting to issue the instructions to copy the match, + * refill the bitbuffer and preload the litlen decode table + * entry for the next loop iteration. This can increase + * performance by allowing the latency of the match copy to + * overlap with these other operations. To further reduce + * latency, we've arranged for there to be enough bits remaining + * to do the table preload independently of the refill, except + * on 32-bit platforms using the byte-at-a-time refill method. + */ + if (!CAN_CONSUME_AND_THEN_PRELOAD( + MAX(OFFSET_MAXBITS - OFFSET_TABLEBITS, + OFFSET_MAXFASTBITS), + LITLEN_TABLEBITS) && + unlikely((u8)bitsleft < LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + + /* + * Copy the match. On most CPUs the fastest method is a + * word-at-a-time copy, unconditionally copying about 5 words + * since this is enough for most matches without being too much. + * + * The normal word-at-a-time copy works for offset >= WORDBYTES, + * which is most cases. The case of offset == 1 is also common + * and is worth optimizing for, since it is just RLE encoding of + * the previous byte, which is the result of compressing long + * runs of the same byte. + * + * Writing past the match 'length' is allowed here, since it's + * been ensured there is enough output space left for a slight + * overrun. FASTLOOP_MAX_BYTES_WRITTEN needs to be updated if + * the maximum possible overrun here is changed. + */ + if (UNALIGNED_ACCESS_IS_FAST && offset >= WORDBYTES) { + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + while (dst < out_next) { + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + } + } else if (UNALIGNED_ACCESS_IS_FAST && offset == 1) { + machine_word_t v; + + /* + * This part tends to get auto-vectorized, so keep it + * copying a multiple of 16 bytes at a time. + */ + v = (machine_word_t)0x0101010101010101 * src[0]; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + while (dst < out_next) { + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + } + } else if (UNALIGNED_ACCESS_IS_FAST) { + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + do { + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + } while (dst < out_next); + } else { + *dst++ = *src++; + *dst++ = *src++; + do { + *dst++ = *src++; + } while (dst < out_next); + } + } while (in_next < in_fastloop_end && out_next < out_fastloop_end); + + /* + * This is the generic loop for decoding literals and matches. This + * handles cases where in_next and out_next are close to the end of + * their respective buffers. Usually this loop isn't performance- + * critical, as most time is spent in the fastloop above instead. We + * therefore omit some optimizations here in favor of smaller code. + */ +generic_loop: + for (;;) { + u32 length, offset; + const u8 *src; + u8 *dst; + + REFILL_BITS(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + if (unlikely(entry & HUFFDEC_SUBTABLE_POINTER)) { + entry = d->u.litlen_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + } + length = entry >> 16; + if (entry & HUFFDEC_LITERAL) { + if (unlikely(out_next == out_end)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + *out_next++ = length; + continue; + } + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + length += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + if (unlikely(length > out_end - out_next)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + + if (!CAN_CONSUME(LENGTH_MAXBITS + OFFSET_MAXBITS)) + REFILL_BITS(); + entry = d->offset_decode_table[bitbuf & BITMASK(OFFSET_TABLEBITS)]; + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + if (!CAN_CONSUME(OFFSET_MAXBITS)) + REFILL_BITS(); + } + offset = entry >> 16; + offset += EXTRACT_VARBITS8(bitbuf, entry) >> (u8)(entry >> 8); + bitbuf >>= (u8)entry; + bitsleft -= entry; + + SAFETY_CHECK(offset <= out_next - (const u8 *)out); + src = out_next - offset; + dst = out_next; + out_next += length; + + STATIC_ASSERT(DEFLATE_MIN_MATCH_LEN == 3); + *dst++ = *src++; + *dst++ = *src++; + do { + *dst++ = *src++; + } while (dst < out_next); + } + +block_done: + /* Finished decoding a block */ + + if (!is_final_block) + goto next_block; + + /* That was the last block. */ + + bitsleft = (u8)bitsleft; + + /* + * If any of the implicit appended zero bytes were consumed (not just + * refilled) before hitting end of stream, then the data is bad. + */ + SAFETY_CHECK(overread_count <= (bitsleft >> 3)); + + /* Optionally return the actual number of bytes consumed. */ + if (actual_in_nbytes_ret) { + /* Don't count bytes that were refilled but not consumed. */ + in_next -= (bitsleft >> 3) - overread_count; + + *actual_in_nbytes_ret = in_next - (u8 *)in; + } + + /* Optionally return the actual number of bytes written. */ + if (actual_out_nbytes_ret) { + *actual_out_nbytes_ret = out_next - (u8 *)out; + } else { + if (out_next != out_end) + return LIBDEFLATE_SHORT_OUTPUT; + } + return LIBDEFLATE_SUCCESS; +} + +#undef FUNCNAME +#undef ATTRIBUTES +#undef EXTRACT_VARBITS +#undef EXTRACT_VARBITS8 + + +/* Include architecture-specific implementation(s) if available. */ +#undef DEFAULT_IMPL +#undef arch_select_decompress_func +#if defined(ARCH_X86_32) || defined(ARCH_X86_64) +#ifndef LIB_X86_DECOMPRESS_IMPL_H +#define LIB_X86_DECOMPRESS_IMPL_H + +/* + * BMI2 optimized version + * + * FIXME: with MSVC, this isn't actually compiled with BMI2 code generation + * enabled yet. That would require that this be moved to its own .c file. + */ +#if HAVE_BMI2_INTRIN +# define deflate_decompress_bmi2 deflate_decompress_bmi2 +# define FUNCNAME deflate_decompress_bmi2 +# if !HAVE_BMI2_NATIVE +# define ATTRIBUTES _target_attribute("bmi2") +# endif + /* + * Even with __attribute__((target("bmi2"))), gcc doesn't reliably use the + * bzhi instruction for 'word & BITMASK(count)'. So use the bzhi intrinsic + * explicitly. EXTRACT_VARBITS() is equivalent to 'word & BITMASK(count)'; + * EXTRACT_VARBITS8() is equivalent to 'word & BITMASK((u8)count)'. + * Nevertheless, their implementation using the bzhi intrinsic is identical, + * as the bzhi instruction truncates the count to 8 bits implicitly. + */ +# ifndef __clang__ +# include +# ifdef ARCH_X86_64 +# define EXTRACT_VARBITS(word, count) _bzhi_u64((word), (count)) +# define EXTRACT_VARBITS8(word, count) _bzhi_u64((word), (count)) +# else +# define EXTRACT_VARBITS(word, count) _bzhi_u32((word), (count)) +# define EXTRACT_VARBITS8(word, count) _bzhi_u32((word), (count)) +# endif +# endif +/* + * decompress_template.h + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +/* + * This is the actual DEFLATE decompression routine, lifted out of + * deflate_decompress.c so that it can be compiled multiple times with different + * target instruction sets. + */ + +#ifndef ATTRIBUTES +# define ATTRIBUTES +#endif +#ifndef EXTRACT_VARBITS +# define EXTRACT_VARBITS(word, count) ((word) & BITMASK(count)) +#endif +#ifndef EXTRACT_VARBITS8 +# define EXTRACT_VARBITS8(word, count) ((word) & BITMASK((u8)(count))) +#endif + +static enum libdeflate_result ATTRIBUTES MAYBE_UNUSED +FUNCNAME(struct libdeflate_decompressor * restrict d, + const void * restrict in, size_t in_nbytes, + void * restrict out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, size_t *actual_out_nbytes_ret) +{ + u8 *out_next = out; + u8 * const out_end = out_next + out_nbytes_avail; + u8 * const out_fastloop_end = + out_end - MIN(out_nbytes_avail, FASTLOOP_MAX_BYTES_WRITTEN); + + /* Input bitstream state; see deflate_decompress.c for documentation */ + const u8 *in_next = in; + const u8 * const in_end = in_next + in_nbytes; + const u8 * const in_fastloop_end = + in_end - MIN(in_nbytes, FASTLOOP_MAX_BYTES_READ); + bitbuf_t bitbuf = 0; + bitbuf_t saved_bitbuf; + u32 bitsleft = 0; + size_t overread_count = 0; + + bool is_final_block; + unsigned block_type; + unsigned num_litlen_syms; + unsigned num_offset_syms; + bitbuf_t litlen_tablemask; + u32 entry; + +next_block: + /* Starting to read the next block */ + ; + + STATIC_ASSERT(CAN_CONSUME(1 + 2 + 5 + 5 + 4 + 3)); + REFILL_BITS(); + + /* BFINAL: 1 bit */ + is_final_block = bitbuf & BITMASK(1); + + /* BTYPE: 2 bits */ + block_type = (bitbuf >> 1) & BITMASK(2); + + if (block_type == DEFLATE_BLOCKTYPE_DYNAMIC_HUFFMAN) { + + /* Dynamic Huffman block */ + + /* The order in which precode lengths are stored */ + static const u8 deflate_precode_lens_permutation[DEFLATE_NUM_PRECODE_SYMS] = { + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 + }; + + unsigned num_explicit_precode_lens; + unsigned i; + + /* Read the codeword length counts. */ + + STATIC_ASSERT(DEFLATE_NUM_LITLEN_SYMS == 257 + BITMASK(5)); + num_litlen_syms = 257 + ((bitbuf >> 3) & BITMASK(5)); + + STATIC_ASSERT(DEFLATE_NUM_OFFSET_SYMS == 1 + BITMASK(5)); + num_offset_syms = 1 + ((bitbuf >> 8) & BITMASK(5)); + + STATIC_ASSERT(DEFLATE_NUM_PRECODE_SYMS == 4 + BITMASK(4)); + num_explicit_precode_lens = 4 + ((bitbuf >> 13) & BITMASK(4)); + + d->static_codes_loaded = false; + + /* + * Read the precode codeword lengths. + * + * A 64-bit bitbuffer is just one bit too small to hold the + * maximum number of precode lens, so to minimize branches we + * merge one len with the previous fields. + */ + STATIC_ASSERT(DEFLATE_MAX_PRE_CODEWORD_LEN == (1 << 3) - 1); + if (CAN_CONSUME(3 * (DEFLATE_NUM_PRECODE_SYMS - 1))) { + d->u.precode_lens[deflate_precode_lens_permutation[0]] = + (bitbuf >> 17) & BITMASK(3); + bitbuf >>= 20; + bitsleft -= 20; + REFILL_BITS(); + i = 1; + do { + d->u.precode_lens[deflate_precode_lens_permutation[i]] = + bitbuf & BITMASK(3); + bitbuf >>= 3; + bitsleft -= 3; + } while (++i < num_explicit_precode_lens); + } else { + bitbuf >>= 17; + bitsleft -= 17; + i = 0; + do { + if ((u8)bitsleft < 3) + REFILL_BITS(); + d->u.precode_lens[deflate_precode_lens_permutation[i]] = + bitbuf & BITMASK(3); + bitbuf >>= 3; + bitsleft -= 3; + } while (++i < num_explicit_precode_lens); + } + for (; i < DEFLATE_NUM_PRECODE_SYMS; i++) + d->u.precode_lens[deflate_precode_lens_permutation[i]] = 0; + + /* Build the decode table for the precode. */ + SAFETY_CHECK(build_precode_decode_table(d)); + + /* Decode the litlen and offset codeword lengths. */ + i = 0; + do { + unsigned presym; + u8 rep_val; + unsigned rep_count; + + if ((u8)bitsleft < DEFLATE_MAX_PRE_CODEWORD_LEN + 7) + REFILL_BITS(); + + /* + * The code below assumes that the precode decode table + * doesn't have any subtables. + */ + STATIC_ASSERT(PRECODE_TABLEBITS == DEFLATE_MAX_PRE_CODEWORD_LEN); + + /* Decode the next precode symbol. */ + entry = d->u.l.precode_decode_table[ + bitbuf & BITMASK(DEFLATE_MAX_PRE_CODEWORD_LEN)]; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + presym = entry >> 16; + + if (presym < 16) { + /* Explicit codeword length */ + d->u.l.lens[i++] = presym; + continue; + } + + /* Run-length encoded codeword lengths */ + + /* + * Note: we don't need to immediately verify that the + * repeat count doesn't overflow the number of elements, + * since we've sized the lens array to have enough extra + * space to allow for the worst-case overrun (138 zeroes + * when only 1 length was remaining). + * + * In the case of the small repeat counts (presyms 16 + * and 17), it is fastest to always write the maximum + * number of entries. That gets rid of branches that + * would otherwise be required. + * + * It is not just because of the numerical order that + * our checks go in the order 'presym < 16', 'presym == + * 16', and 'presym == 17'. For typical data this is + * ordered from most frequent to least frequent case. + */ + STATIC_ASSERT(DEFLATE_MAX_LENS_OVERRUN == 138 - 1); + + if (presym == 16) { + /* Repeat the previous length 3 - 6 times. */ + SAFETY_CHECK(i != 0); + rep_val = d->u.l.lens[i - 1]; + STATIC_ASSERT(3 + BITMASK(2) == 6); + rep_count = 3 + (bitbuf & BITMASK(2)); + bitbuf >>= 2; + bitsleft -= 2; + d->u.l.lens[i + 0] = rep_val; + d->u.l.lens[i + 1] = rep_val; + d->u.l.lens[i + 2] = rep_val; + d->u.l.lens[i + 3] = rep_val; + d->u.l.lens[i + 4] = rep_val; + d->u.l.lens[i + 5] = rep_val; + i += rep_count; + } else if (presym == 17) { + /* Repeat zero 3 - 10 times. */ + STATIC_ASSERT(3 + BITMASK(3) == 10); + rep_count = 3 + (bitbuf & BITMASK(3)); + bitbuf >>= 3; + bitsleft -= 3; + d->u.l.lens[i + 0] = 0; + d->u.l.lens[i + 1] = 0; + d->u.l.lens[i + 2] = 0; + d->u.l.lens[i + 3] = 0; + d->u.l.lens[i + 4] = 0; + d->u.l.lens[i + 5] = 0; + d->u.l.lens[i + 6] = 0; + d->u.l.lens[i + 7] = 0; + d->u.l.lens[i + 8] = 0; + d->u.l.lens[i + 9] = 0; + i += rep_count; + } else { + /* Repeat zero 11 - 138 times. */ + STATIC_ASSERT(11 + BITMASK(7) == 138); + rep_count = 11 + (bitbuf & BITMASK(7)); + bitbuf >>= 7; + bitsleft -= 7; + memset(&d->u.l.lens[i], 0, + rep_count * sizeof(d->u.l.lens[i])); + i += rep_count; + } + } while (i < num_litlen_syms + num_offset_syms); + + /* Unnecessary, but check this for consistency with zlib. */ + SAFETY_CHECK(i == num_litlen_syms + num_offset_syms); + + } else if (block_type == DEFLATE_BLOCKTYPE_UNCOMPRESSED) { + u16 len, nlen; + + /* + * Uncompressed block: copy 'len' bytes literally from the input + * buffer to the output buffer. + */ + + bitsleft -= 3; /* for BTYPE and BFINAL */ + + /* + * Align the bitstream to the next byte boundary. This means + * the next byte boundary as if we were reading a byte at a + * time. Therefore, we have to rewind 'in_next' by any bytes + * that have been refilled but not actually consumed yet (not + * counting overread bytes, which don't increment 'in_next'). + */ + bitsleft = (u8)bitsleft; + SAFETY_CHECK(overread_count <= (bitsleft >> 3)); + in_next -= (bitsleft >> 3) - overread_count; + overread_count = 0; + bitbuf = 0; + bitsleft = 0; + + SAFETY_CHECK(in_end - in_next >= 4); + len = get_unaligned_le16(in_next); + nlen = get_unaligned_le16(in_next + 2); + in_next += 4; + + SAFETY_CHECK(len == (u16)~nlen); + if (unlikely(len > out_end - out_next)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + SAFETY_CHECK(len <= in_end - in_next); + + memcpy(out_next, in_next, len); + in_next += len; + out_next += len; + + goto block_done; + + } else { + unsigned i; + + SAFETY_CHECK(block_type == DEFLATE_BLOCKTYPE_STATIC_HUFFMAN); + + /* + * Static Huffman block: build the decode tables for the static + * codes. Skip doing so if the tables are already set up from + * an earlier static block; this speeds up decompression of + * degenerate input of many empty or very short static blocks. + * + * Afterwards, the remainder is the same as decompressing a + * dynamic Huffman block. + */ + + bitbuf >>= 3; /* for BTYPE and BFINAL */ + bitsleft -= 3; + + if (d->static_codes_loaded) + goto have_decode_tables; + + d->static_codes_loaded = true; + + STATIC_ASSERT(DEFLATE_NUM_LITLEN_SYMS == 288); + STATIC_ASSERT(DEFLATE_NUM_OFFSET_SYMS == 32); + + for (i = 0; i < 144; i++) + d->u.l.lens[i] = 8; + for (; i < 256; i++) + d->u.l.lens[i] = 9; + for (; i < 280; i++) + d->u.l.lens[i] = 7; + for (; i < 288; i++) + d->u.l.lens[i] = 8; + + for (; i < 288 + 32; i++) + d->u.l.lens[i] = 5; + + num_litlen_syms = 288; + num_offset_syms = 32; + } + + /* Decompressing a Huffman block (either dynamic or static) */ + + SAFETY_CHECK(build_offset_decode_table(d, num_litlen_syms, num_offset_syms)); + SAFETY_CHECK(build_litlen_decode_table(d, num_litlen_syms, num_offset_syms)); +have_decode_tables: + litlen_tablemask = BITMASK(d->litlen_tablebits); + + /* + * This is the "fastloop" for decoding literals and matches. It does + * bounds checks on in_next and out_next in the loop conditions so that + * additional bounds checks aren't needed inside the loop body. + * + * To reduce latency, the bitbuffer is refilled and the next litlen + * decode table entry is preloaded before each loop iteration. + */ + if (in_next >= in_fastloop_end || out_next >= out_fastloop_end) + goto generic_loop; + REFILL_BITS_IN_FASTLOOP(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + do { + u32 length, offset, lit; + const u8 *src; + u8 *dst; + + /* + * Consume the bits for the litlen decode table entry. Save the + * original bitbuf for later, in case the extra match length + * bits need to be extracted from it. + */ + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + + /* + * Begin by checking for a "fast" literal, i.e. a literal that + * doesn't need a subtable. + */ + if (entry & HUFFDEC_LITERAL) { + /* + * On 64-bit platforms, we decode up to 2 extra fast + * literals in addition to the primary item, as this + * increases performance and still leaves enough bits + * remaining for what follows. We could actually do 3, + * assuming LITLEN_TABLEBITS=11, but that actually + * decreases performance slightly (perhaps by messing + * with the branch prediction of the conditional refill + * that happens later while decoding the match offset). + * + * Note: the definitions of FASTLOOP_MAX_BYTES_WRITTEN + * and FASTLOOP_MAX_BYTES_READ need to be updated if the + * number of extra literals decoded here is changed. + */ + if (/* enough bits for 2 fast literals + length + offset preload? */ + CAN_CONSUME_AND_THEN_PRELOAD(2 * LITLEN_TABLEBITS + + LENGTH_MAXBITS, + OFFSET_TABLEBITS) && + /* enough bits for 2 fast literals + slow literal + litlen preload? */ + CAN_CONSUME_AND_THEN_PRELOAD(2 * LITLEN_TABLEBITS + + DEFLATE_MAX_LITLEN_CODEWORD_LEN, + LITLEN_TABLEBITS)) { + /* 1st extra fast literal */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + *out_next++ = lit; + if (entry & HUFFDEC_LITERAL) { + /* 2nd extra fast literal */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + *out_next++ = lit; + if (entry & HUFFDEC_LITERAL) { + /* + * Another fast literal, but + * this one is in lieu of the + * primary item, so it doesn't + * count as one of the extras. + */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + } + } else { + /* + * Decode a literal. While doing so, preload + * the next litlen decode table entry and refill + * the bitbuffer. To reduce latency, we've + * arranged for there to be enough "preloadable" + * bits remaining to do the table preload + * independently of the refill. + */ + STATIC_ASSERT(CAN_CONSUME_AND_THEN_PRELOAD( + LITLEN_TABLEBITS, LITLEN_TABLEBITS)); + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + } + + /* + * It's not a literal entry, so it can be a length entry, a + * subtable pointer entry, or an end-of-block entry. Detect the + * two unlikely cases by testing the HUFFDEC_EXCEPTIONAL flag. + */ + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Subtable pointer or end-of-block entry */ + + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + + /* + * A subtable is required. Load and consume the + * subtable entry. The subtable entry can be of any + * type: literal, length, or end-of-block. + */ + entry = d->u.litlen_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + + /* + * 32-bit platforms that use the byte-at-a-time refill + * method have to do a refill here for there to always + * be enough bits to decode a literal that requires a + * subtable, then preload the next litlen decode table + * entry; or to decode a match length that requires a + * subtable, then preload the offset decode table entry. + */ + if (!CAN_CONSUME_AND_THEN_PRELOAD(DEFLATE_MAX_LITLEN_CODEWORD_LEN, + LITLEN_TABLEBITS) || + !CAN_CONSUME_AND_THEN_PRELOAD(LENGTH_MAXBITS, + OFFSET_TABLEBITS)) + REFILL_BITS_IN_FASTLOOP(); + if (entry & HUFFDEC_LITERAL) { + /* Decode a literal that required a subtable. */ + lit = entry >> 16; + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + *out_next++ = lit; + continue; + } + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + /* Else, it's a length that required a subtable. */ + } + + /* + * Decode the match length: the length base value associated + * with the litlen symbol (which we extract from the decode + * table entry), plus the extra length bits. We don't need to + * consume the extra length bits here, as they were included in + * the bits consumed by the entry earlier. We also don't need + * to check for too-long matches here, as this is inside the + * fastloop where it's already been verified that the output + * buffer has enough space remaining to copy a max-length match. + */ + length = entry >> 16; + length += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + + /* + * Decode the match offset. There are enough "preloadable" bits + * remaining to preload the offset decode table entry, but a + * refill might be needed before consuming it. + */ + STATIC_ASSERT(CAN_CONSUME_AND_THEN_PRELOAD(LENGTH_MAXFASTBITS, + OFFSET_TABLEBITS)); + entry = d->offset_decode_table[bitbuf & BITMASK(OFFSET_TABLEBITS)]; + if (CAN_CONSUME_AND_THEN_PRELOAD(OFFSET_MAXBITS, + LITLEN_TABLEBITS)) { + /* + * Decoding a match offset on a 64-bit platform. We may + * need to refill once, but then we can decode the whole + * offset and preload the next litlen table entry. + */ + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Offset codeword requires a subtable */ + if (unlikely((u8)bitsleft < OFFSET_MAXBITS + + LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + } else if (unlikely((u8)bitsleft < OFFSET_MAXFASTBITS + + LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + } else { + /* Decoding a match offset on a 32-bit platform */ + REFILL_BITS_IN_FASTLOOP(); + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + /* Offset codeword requires a subtable */ + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + REFILL_BITS_IN_FASTLOOP(); + /* No further refill needed before extra bits */ + STATIC_ASSERT(CAN_CONSUME( + OFFSET_MAXBITS - OFFSET_TABLEBITS)); + } else { + /* No refill needed before extra bits */ + STATIC_ASSERT(CAN_CONSUME(OFFSET_MAXFASTBITS)); + } + } + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; /* optimization: subtract full entry */ + offset = entry >> 16; + offset += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + + /* Validate the match offset; needed even in the fastloop. */ + SAFETY_CHECK(offset <= out_next - (const u8 *)out); + src = out_next - offset; + dst = out_next; + out_next += length; + + /* + * Before starting to issue the instructions to copy the match, + * refill the bitbuffer and preload the litlen decode table + * entry for the next loop iteration. This can increase + * performance by allowing the latency of the match copy to + * overlap with these other operations. To further reduce + * latency, we've arranged for there to be enough bits remaining + * to do the table preload independently of the refill, except + * on 32-bit platforms using the byte-at-a-time refill method. + */ + if (!CAN_CONSUME_AND_THEN_PRELOAD( + MAX(OFFSET_MAXBITS - OFFSET_TABLEBITS, + OFFSET_MAXFASTBITS), + LITLEN_TABLEBITS) && + unlikely((u8)bitsleft < LITLEN_TABLEBITS - PRELOAD_SLACK)) + REFILL_BITS_IN_FASTLOOP(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + REFILL_BITS_IN_FASTLOOP(); + + /* + * Copy the match. On most CPUs the fastest method is a + * word-at-a-time copy, unconditionally copying about 5 words + * since this is enough for most matches without being too much. + * + * The normal word-at-a-time copy works for offset >= WORDBYTES, + * which is most cases. The case of offset == 1 is also common + * and is worth optimizing for, since it is just RLE encoding of + * the previous byte, which is the result of compressing long + * runs of the same byte. + * + * Writing past the match 'length' is allowed here, since it's + * been ensured there is enough output space left for a slight + * overrun. FASTLOOP_MAX_BYTES_WRITTEN needs to be updated if + * the maximum possible overrun here is changed. + */ + if (UNALIGNED_ACCESS_IS_FAST && offset >= WORDBYTES) { + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + while (dst < out_next) { + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + store_word_unaligned(load_word_unaligned(src), dst); + src += WORDBYTES; + dst += WORDBYTES; + } + } else if (UNALIGNED_ACCESS_IS_FAST && offset == 1) { + machine_word_t v; + + /* + * This part tends to get auto-vectorized, so keep it + * copying a multiple of 16 bytes at a time. + */ + v = (machine_word_t)0x0101010101010101 * src[0]; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + while (dst < out_next) { + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + store_word_unaligned(v, dst); + dst += WORDBYTES; + } + } else if (UNALIGNED_ACCESS_IS_FAST) { + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + do { + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + store_word_unaligned(load_word_unaligned(src), dst); + src += offset; + dst += offset; + } while (dst < out_next); + } else { + *dst++ = *src++; + *dst++ = *src++; + do { + *dst++ = *src++; + } while (dst < out_next); + } + } while (in_next < in_fastloop_end && out_next < out_fastloop_end); + + /* + * This is the generic loop for decoding literals and matches. This + * handles cases where in_next and out_next are close to the end of + * their respective buffers. Usually this loop isn't performance- + * critical, as most time is spent in the fastloop above instead. We + * therefore omit some optimizations here in favor of smaller code. + */ +generic_loop: + for (;;) { + u32 length, offset; + const u8 *src; + u8 *dst; + + REFILL_BITS(); + entry = d->u.litlen_decode_table[bitbuf & litlen_tablemask]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + if (unlikely(entry & HUFFDEC_SUBTABLE_POINTER)) { + entry = d->u.litlen_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + saved_bitbuf = bitbuf; + bitbuf >>= (u8)entry; + bitsleft -= entry; + } + length = entry >> 16; + if (entry & HUFFDEC_LITERAL) { + if (unlikely(out_next == out_end)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + *out_next++ = length; + continue; + } + if (unlikely(entry & HUFFDEC_END_OF_BLOCK)) + goto block_done; + length += EXTRACT_VARBITS8(saved_bitbuf, entry) >> (u8)(entry >> 8); + if (unlikely(length > out_end - out_next)) + return LIBDEFLATE_INSUFFICIENT_SPACE; + + if (!CAN_CONSUME(LENGTH_MAXBITS + OFFSET_MAXBITS)) + REFILL_BITS(); + entry = d->offset_decode_table[bitbuf & BITMASK(OFFSET_TABLEBITS)]; + if (unlikely(entry & HUFFDEC_EXCEPTIONAL)) { + bitbuf >>= OFFSET_TABLEBITS; + bitsleft -= OFFSET_TABLEBITS; + entry = d->offset_decode_table[(entry >> 16) + + EXTRACT_VARBITS(bitbuf, (entry >> 8) & 0x3F)]; + if (!CAN_CONSUME(OFFSET_MAXBITS)) + REFILL_BITS(); + } + offset = entry >> 16; + offset += EXTRACT_VARBITS8(bitbuf, entry) >> (u8)(entry >> 8); + bitbuf >>= (u8)entry; + bitsleft -= entry; + + SAFETY_CHECK(offset <= out_next - (const u8 *)out); + src = out_next - offset; + dst = out_next; + out_next += length; + + STATIC_ASSERT(DEFLATE_MIN_MATCH_LEN == 3); + *dst++ = *src++; + *dst++ = *src++; + do { + *dst++ = *src++; + } while (dst < out_next); + } + +block_done: + /* Finished decoding a block */ + + if (!is_final_block) + goto next_block; + + /* That was the last block. */ + + bitsleft = (u8)bitsleft; + + /* + * If any of the implicit appended zero bytes were consumed (not just + * refilled) before hitting end of stream, then the data is bad. + */ + SAFETY_CHECK(overread_count <= (bitsleft >> 3)); + + /* Optionally return the actual number of bytes consumed. */ + if (actual_in_nbytes_ret) { + /* Don't count bytes that were refilled but not consumed. */ + in_next -= (bitsleft >> 3) - overread_count; + + *actual_in_nbytes_ret = in_next - (u8 *)in; + } + + /* Optionally return the actual number of bytes written. */ + if (actual_out_nbytes_ret) { + *actual_out_nbytes_ret = out_next - (u8 *)out; + } else { + if (out_next != out_end) + return LIBDEFLATE_SHORT_OUTPUT; + } + return LIBDEFLATE_SUCCESS; +} + +#undef FUNCNAME +#undef ATTRIBUTES +#undef EXTRACT_VARBITS +#undef EXTRACT_VARBITS8 + +#endif /* HAVE_BMI2_INTRIN */ + +#if defined(deflate_decompress_bmi2) && HAVE_BMI2_NATIVE +#define DEFAULT_IMPL deflate_decompress_bmi2 +#else +static inline decompress_func_t +arch_select_decompress_func(void) +{ +#ifdef deflate_decompress_bmi2 + if (HAVE_BMI2(get_x86_cpu_features())) + return deflate_decompress_bmi2; +#endif + return NULL; +} +#define arch_select_decompress_func arch_select_decompress_func +#endif + +#endif /* LIB_X86_DECOMPRESS_IMPL_H */ + +#endif + +#ifndef DEFAULT_IMPL +# define DEFAULT_IMPL deflate_decompress_default +#endif + +#ifdef arch_select_decompress_func +static enum libdeflate_result +dispatch_decomp(struct libdeflate_decompressor *d, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, size_t *actual_out_nbytes_ret); + +static volatile decompress_func_t decompress_impl = dispatch_decomp; + +/* Choose the best implementation at runtime. */ +static enum libdeflate_result +dispatch_decomp(struct libdeflate_decompressor *d, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, size_t *actual_out_nbytes_ret) +{ + decompress_func_t f = arch_select_decompress_func(); + + if (f == NULL) + f = DEFAULT_IMPL; + + decompress_impl = f; + return f(d, in, in_nbytes, out, out_nbytes_avail, + actual_in_nbytes_ret, actual_out_nbytes_ret); +} +#else +/* The best implementation is statically known, so call it directly. */ +# define decompress_impl DEFAULT_IMPL +#endif + +/* + * This is the main DEFLATE decompression routine. See libdeflate.h for the + * documentation. + * + * Note that the real code is in decompress_template.h. The part here just + * handles calling the appropriate implementation depending on the CPU features + * at runtime. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_deflate_decompress_ex(struct libdeflate_decompressor *d, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, + size_t *actual_out_nbytes_ret) +{ + return decompress_impl(d, in, in_nbytes, out, out_nbytes_avail, + actual_in_nbytes_ret, actual_out_nbytes_ret); +} + +LIBDEFLATEAPI enum libdeflate_result +libdeflate_deflate_decompress(struct libdeflate_decompressor *d, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_out_nbytes_ret) +{ + return libdeflate_deflate_decompress_ex(d, in, in_nbytes, + out, out_nbytes_avail, + NULL, actual_out_nbytes_ret); +} + +LIBDEFLATEAPI struct libdeflate_decompressor * +libdeflate_alloc_decompressor_ex(const struct libdeflate_options *options) +{ + struct libdeflate_decompressor *d; + + /* + * Note: if more fields are added to libdeflate_options, this code will + * need to be updated to support both the old and new structs. + */ + if (options->sizeof_options != sizeof(*options)) + return NULL; + + d = (options->malloc_func ? options->malloc_func : + libdeflate_default_malloc_func)(sizeof(*d)); + if (d == NULL) + return NULL; + /* + * Note that only certain parts of the decompressor actually must be + * initialized here: + * + * - 'static_codes_loaded' must be initialized to false. + * + * - The first half of the main portion of each decode table must be + * initialized to any value, to avoid reading from uninitialized + * memory during table expansion in build_decode_table(). (Although, + * this is really just to avoid warnings with dynamic tools like + * valgrind, since build_decode_table() is guaranteed to initialize + * all entries eventually anyway.) + * + * - 'free_func' must be set. + * + * But for simplicity, we currently just zero the whole decompressor. + */ + memset(d, 0, sizeof(*d)); + d->free_func = options->free_func ? + options->free_func : libdeflate_default_free_func; + return d; +} + +LIBDEFLATEAPI struct libdeflate_decompressor * +libdeflate_alloc_decompressor(void) +{ + static const struct libdeflate_options defaults = { + .sizeof_options = sizeof(defaults), + }; + return libdeflate_alloc_decompressor_ex(&defaults); +} + +LIBDEFLATEAPI void +libdeflate_free_decompressor(struct libdeflate_decompressor *d) +{ + if (d) + d->free_func(d); +} + + +/* + * utils.c - utility functions for libdeflate + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#ifdef FREESTANDING +# define malloc NULL +# define free NULL +#else +# include +#endif + +malloc_func_t libdeflate_default_malloc_func = malloc; +free_func_t libdeflate_default_free_func = free; + +void * +libdeflate_aligned_malloc(malloc_func_t malloc_func, + size_t alignment, size_t size) +{ + void *ptr = (*malloc_func)(sizeof(void *) + alignment - 1 + size); + + if (ptr) { + void *orig_ptr = ptr; + + ptr = (void *)ALIGN((uintptr_t)ptr + sizeof(void *), alignment); + ((void **)ptr)[-1] = orig_ptr; + } + return ptr; +} + +void +libdeflate_aligned_free(free_func_t free_func, void *ptr) +{ + (*free_func)(((void **)ptr)[-1]); +} + +LIBDEFLATEAPI void +libdeflate_set_memory_allocator(malloc_func_t malloc_func, + free_func_t free_func) +{ + libdeflate_default_malloc_func = malloc_func; + libdeflate_default_free_func = free_func; +} + +/* + * Implementations of libc functions for freestanding library builds. + * Normal library builds don't use these. Not optimized yet; usually the + * compiler expands these functions and doesn't actually call them anyway. + */ +#ifdef FREESTANDING +#undef memset +void * __attribute__((weak)) +memset(void *s, int c, size_t n) +{ + u8 *p = s; + size_t i; + + for (i = 0; i < n; i++) + p[i] = c; + return s; +} + +#undef memcpy +void * __attribute__((weak)) +memcpy(void *dest, const void *src, size_t n) +{ + u8 *d = dest; + const u8 *s = src; + size_t i; + + for (i = 0; i < n; i++) + d[i] = s[i]; + return dest; +} + +#undef memmove +void * __attribute__((weak)) +memmove(void *dest, const void *src, size_t n) +{ + u8 *d = dest; + const u8 *s = src; + size_t i; + + if (d <= s) + return memcpy(d, s, n); + + for (i = n; i > 0; i--) + d[i - 1] = s[i - 1]; + return dest; +} + +#undef memcmp +int __attribute__((weak)) +memcmp(const void *s1, const void *s2, size_t n) +{ + const u8 *p1 = s1; + const u8 *p2 = s2; + size_t i; + + for (i = 0; i < n; i++) { + if (p1[i] != p2[i]) + return (int)p1[i] - (int)p2[i]; + } + return 0; +} +#endif /* FREESTANDING */ + +#ifdef LIBDEFLATE_ENABLE_ASSERTIONS +#include +#include +void +libdeflate_assertion_failed(const char *expr, const char *file, int line) +{ + fprintf(stderr, "Assertion failed: %s at %s:%d\n", expr, file, line); + abort(); +} +#endif /* LIBDEFLATE_ENABLE_ASSERTIONS */ + +/* + * x86/cpu_features.c - feature detection for x86 CPUs + * + * Copyright 2016 Eric Biggers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#if HAVE_DYNAMIC_X86_CPU_FEATURES + +/* + * With old GCC versions we have to manually save and restore the x86_32 PIC + * register (ebx). See: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=47602 + */ +#if defined(ARCH_X86_32) && defined(__PIC__) +# define EBX_CONSTRAINT "=&r" +#else +# define EBX_CONSTRAINT "=b" +#endif + +/* Execute the CPUID instruction. */ +static inline void +cpuid(u32 leaf, u32 subleaf, u32 *a, u32 *b, u32 *c, u32 *d) +{ +#ifdef _MSC_VER + int result[4]; + + __cpuidex(result, leaf, subleaf); + *a = result[0]; + *b = result[1]; + *c = result[2]; + *d = result[3]; +#else + __asm__ volatile(".ifnc %%ebx, %1; mov %%ebx, %1; .endif\n" + "cpuid \n" + ".ifnc %%ebx, %1; xchg %%ebx, %1; .endif\n" + : "=a" (*a), EBX_CONSTRAINT (*b), "=c" (*c), "=d" (*d) + : "a" (leaf), "c" (subleaf)); +#endif +} + +/* Read an extended control register. */ +static inline u64 +read_xcr(u32 index) +{ +#ifdef _MSC_VER + return _xgetbv(index); +#else + u32 d, a; + + /* + * Execute the "xgetbv" instruction. Old versions of binutils do not + * recognize this instruction, so list the raw bytes instead. + * + * This must be 'volatile' to prevent this code from being moved out + * from under the check for OSXSAVE. + */ + __asm__ volatile(".byte 0x0f, 0x01, 0xd0" : + "=d" (d), "=a" (a) : "c" (index)); + + return ((u64)d << 32) | a; +#endif +} + +static const struct cpu_feature x86_cpu_feature_table[] = { + {X86_CPU_FEATURE_SSE2, "sse2"}, + {X86_CPU_FEATURE_PCLMUL, "pclmul"}, + {X86_CPU_FEATURE_AVX, "avx"}, + {X86_CPU_FEATURE_AVX2, "avx2"}, + {X86_CPU_FEATURE_BMI2, "bmi2"}, +}; + +volatile u32 libdeflate_x86_cpu_features = 0; + +/* Initialize libdeflate_x86_cpu_features. */ +void libdeflate_init_x86_cpu_features(void) +{ + u32 max_leaf, a, b, c, d; + u64 xcr0 = 0; + u32 features = 0; + + /* EAX=0: Highest Function Parameter and Manufacturer ID */ + cpuid(0, 0, &max_leaf, &b, &c, &d); + if (max_leaf < 1) + goto out; + + /* EAX=1: Processor Info and Feature Bits */ + cpuid(1, 0, &a, &b, &c, &d); + if (d & (1 << 26)) + features |= X86_CPU_FEATURE_SSE2; + if (c & (1 << 1)) + features |= X86_CPU_FEATURE_PCLMUL; + if (c & (1 << 27)) + xcr0 = read_xcr(0); + if ((c & (1 << 28)) && ((xcr0 & 0x6) == 0x6)) + features |= X86_CPU_FEATURE_AVX; + + if (max_leaf < 7) + goto out; + + /* EAX=7, ECX=0: Extended Features */ + cpuid(7, 0, &a, &b, &c, &d); + if ((b & (1 << 5)) && ((xcr0 & 0x6) == 0x6)) + features |= X86_CPU_FEATURE_AVX2; + if (b & (1 << 8)) + features |= X86_CPU_FEATURE_BMI2; + +out: + disable_cpu_features_for_testing(&features, x86_cpu_feature_table, + ARRAY_LEN(x86_cpu_feature_table)); + + libdeflate_x86_cpu_features = features | X86_CPU_FEATURES_KNOWN; +} + +#endif /* HAVE_DYNAMIC_X86_CPU_FEATURES */ diff --git a/Plugins/nosGeometry/External/openFBX/libdeflate.h b/Plugins/nosGeometry/External/openFBX/libdeflate.h new file mode 100644 index 00000000..382d895d --- /dev/null +++ b/Plugins/nosGeometry/External/openFBX/libdeflate.h @@ -0,0 +1,411 @@ +/* + * libdeflate.h - public header for libdeflate + */ + +#ifndef LIBDEFLATE_H +#define LIBDEFLATE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LIBDEFLATE_VERSION_MAJOR 1 +#define LIBDEFLATE_VERSION_MINOR 18 +#define LIBDEFLATE_VERSION_STRING "1.18" + +/* + * Users of libdeflate.dll on Windows can define LIBDEFLATE_DLL to cause + * __declspec(dllimport) to be used. This should be done when it's easy to do. + * Otherwise it's fine to skip it, since it is a very minor performance + * optimization that is irrelevant for most use cases of libdeflate. + */ +#ifndef LIBDEFLATEAPI +# if defined(LIBDEFLATE_DLL) && (defined(_WIN32) || defined(__CYGWIN__)) +# define LIBDEFLATEAPI __declspec(dllimport) +# else +# define LIBDEFLATEAPI +# endif +#endif + +/* ========================================================================== */ +/* Compression */ +/* ========================================================================== */ + +struct libdeflate_compressor; +struct libdeflate_options; + +/* + * libdeflate_alloc_compressor() allocates a new compressor that supports + * DEFLATE, zlib, and gzip compression. 'compression_level' is the compression + * level on a zlib-like scale but with a higher maximum value (1 = fastest, 6 = + * medium/default, 9 = slow, 12 = slowest). Level 0 is also supported and means + * "no compression", specifically "create a valid stream, but only emit + * uncompressed blocks" (this will expand the data slightly). + * + * The return value is a pointer to the new compressor, or NULL if out of memory + * or if the compression level is invalid (i.e. outside the range [0, 12]). + * + * Note: for compression, the sliding window size is defined at compilation time + * to 32768, the largest size permissible in the DEFLATE format. It cannot be + * changed at runtime. + * + * A single compressor is not safe to use by multiple threads concurrently. + * However, different threads may use different compressors concurrently. + */ +LIBDEFLATEAPI struct libdeflate_compressor * +libdeflate_alloc_compressor(int compression_level); + +/* + * Like libdeflate_alloc_compressor(), but adds the 'options' argument. + */ +LIBDEFLATEAPI struct libdeflate_compressor * +libdeflate_alloc_compressor_ex(int compression_level, + const struct libdeflate_options *options); + +/* + * libdeflate_deflate_compress() performs raw DEFLATE compression on a buffer of + * data. It attempts to compress 'in_nbytes' bytes of data located at 'in' and + * write the result to 'out', which has space for 'out_nbytes_avail' bytes. The + * return value is the compressed size in bytes, or 0 if the data could not be + * compressed to 'out_nbytes_avail' bytes or fewer (but see note below). + * + * If compression is successful, then the output data is guaranteed to be a + * valid DEFLATE stream that decompresses to the input data. No other + * guarantees are made about the output data. Notably, different versions of + * libdeflate can produce different compressed data for the same uncompressed + * data, even at the same compression level. Do ***NOT*** do things like + * writing tests that compare compressed data to a golden output, as this can + * break when libdeflate is updated. (This property isn't specific to + * libdeflate; the same is true for zlib and other compression libraries too.) + */ +LIBDEFLATEAPI size_t +libdeflate_deflate_compress(struct libdeflate_compressor *compressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail); + +/* + * libdeflate_deflate_compress_bound() returns a worst-case upper bound on the + * number of bytes of compressed data that may be produced by compressing any + * buffer of length less than or equal to 'in_nbytes' using + * libdeflate_deflate_compress() with the specified compressor. This bound will + * necessarily be a number greater than or equal to 'in_nbytes'. It may be an + * overestimate of the true upper bound. The return value is guaranteed to be + * the same for all invocations with the same compressor and same 'in_nbytes'. + * + * As a special case, 'compressor' may be NULL. This causes the bound to be + * taken across *any* libdeflate_compressor that could ever be allocated with + * this build of the library, with any options. + * + * Note that this function is not necessary in many applications. With + * block-based compression, it is usually preferable to separately store the + * uncompressed size of each block and to store any blocks that did not compress + * to less than their original size uncompressed. In that scenario, there is no + * need to know the worst-case compressed size, since the maximum number of + * bytes of compressed data that may be used would always be one less than the + * input length. You can just pass a buffer of that size to + * libdeflate_deflate_compress() and store the data uncompressed if + * libdeflate_deflate_compress() returns 0, indicating that the compressed data + * did not fit into the provided output buffer. + */ +LIBDEFLATEAPI size_t +libdeflate_deflate_compress_bound(struct libdeflate_compressor *compressor, + size_t in_nbytes); + +/* + * Like libdeflate_deflate_compress(), but uses the zlib wrapper format instead + * of raw DEFLATE. + */ +LIBDEFLATEAPI size_t +libdeflate_zlib_compress(struct libdeflate_compressor *compressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail); + +/* + * Like libdeflate_deflate_compress_bound(), but assumes the data will be + * compressed with libdeflate_zlib_compress() rather than with + * libdeflate_deflate_compress(). + */ +LIBDEFLATEAPI size_t +libdeflate_zlib_compress_bound(struct libdeflate_compressor *compressor, + size_t in_nbytes); + +/* + * Like libdeflate_deflate_compress(), but uses the gzip wrapper format instead + * of raw DEFLATE. + */ +LIBDEFLATEAPI size_t +libdeflate_gzip_compress(struct libdeflate_compressor *compressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail); + +/* + * Like libdeflate_deflate_compress_bound(), but assumes the data will be + * compressed with libdeflate_gzip_compress() rather than with + * libdeflate_deflate_compress(). + */ +LIBDEFLATEAPI size_t +libdeflate_gzip_compress_bound(struct libdeflate_compressor *compressor, + size_t in_nbytes); + +/* + * libdeflate_free_compressor() frees a compressor that was allocated with + * libdeflate_alloc_compressor(). If a NULL pointer is passed in, no action is + * taken. + */ +LIBDEFLATEAPI void +libdeflate_free_compressor(struct libdeflate_compressor *compressor); + +/* ========================================================================== */ +/* Decompression */ +/* ========================================================================== */ + +struct libdeflate_decompressor; +struct libdeflate_options; + +/* + * libdeflate_alloc_decompressor() allocates a new decompressor that can be used + * for DEFLATE, zlib, and gzip decompression. The return value is a pointer to + * the new decompressor, or NULL if out of memory. + * + * This function takes no parameters, and the returned decompressor is valid for + * decompressing data that was compressed at any compression level and with any + * sliding window size. + * + * A single decompressor is not safe to use by multiple threads concurrently. + * However, different threads may use different decompressors concurrently. + */ +LIBDEFLATEAPI struct libdeflate_decompressor * +libdeflate_alloc_decompressor(void); + +/* + * Like libdeflate_alloc_decompressor(), but adds the 'options' argument. + */ +LIBDEFLATEAPI struct libdeflate_decompressor * +libdeflate_alloc_decompressor_ex(const struct libdeflate_options *options); + +/* + * Result of a call to libdeflate_deflate_decompress(), + * libdeflate_zlib_decompress(), or libdeflate_gzip_decompress(). + */ +enum libdeflate_result { + /* Decompression was successful. */ + LIBDEFLATE_SUCCESS = 0, + + /* Decompression failed because the compressed data was invalid, + * corrupt, or otherwise unsupported. */ + LIBDEFLATE_BAD_DATA = 1, + + /* A NULL 'actual_out_nbytes_ret' was provided, but the data would have + * decompressed to fewer than 'out_nbytes_avail' bytes. */ + LIBDEFLATE_SHORT_OUTPUT = 2, + + /* The data would have decompressed to more than 'out_nbytes_avail' + * bytes. */ + LIBDEFLATE_INSUFFICIENT_SPACE = 3, +}; + +/* + * libdeflate_deflate_decompress() decompresses a DEFLATE stream from the buffer + * 'in' with compressed size up to 'in_nbytes' bytes. The uncompressed data is + * written to 'out', a buffer with size 'out_nbytes_avail' bytes. If + * decompression succeeds, then 0 (LIBDEFLATE_SUCCESS) is returned. Otherwise, + * a nonzero result code such as LIBDEFLATE_BAD_DATA is returned, and the + * contents of the output buffer are undefined. + * + * Decompression stops at the end of the DEFLATE stream (as indicated by the + * BFINAL flag), even if it is actually shorter than 'in_nbytes' bytes. + * + * libdeflate_deflate_decompress() can be used in cases where the actual + * uncompressed size is known (recommended) or unknown (not recommended): + * + * - If the actual uncompressed size is known, then pass the actual + * uncompressed size as 'out_nbytes_avail' and pass NULL for + * 'actual_out_nbytes_ret'. This makes libdeflate_deflate_decompress() fail + * with LIBDEFLATE_SHORT_OUTPUT if the data decompressed to fewer than the + * specified number of bytes. + * + * - If the actual uncompressed size is unknown, then provide a non-NULL + * 'actual_out_nbytes_ret' and provide a buffer with some size + * 'out_nbytes_avail' that you think is large enough to hold all the + * uncompressed data. In this case, if the data decompresses to less than + * or equal to 'out_nbytes_avail' bytes, then + * libdeflate_deflate_decompress() will write the actual uncompressed size + * to *actual_out_nbytes_ret and return 0 (LIBDEFLATE_SUCCESS). Otherwise, + * it will return LIBDEFLATE_INSUFFICIENT_SPACE if the provided buffer was + * not large enough but no other problems were encountered, or another + * nonzero result code if decompression failed for another reason. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_deflate_decompress(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_out_nbytes_ret); + +/* + * Like libdeflate_deflate_decompress(), but adds the 'actual_in_nbytes_ret' + * argument. If decompression succeeds and 'actual_in_nbytes_ret' is not NULL, + * then the actual compressed size of the DEFLATE stream (aligned to the next + * byte boundary) is written to *actual_in_nbytes_ret. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_deflate_decompress_ex(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, + size_t *actual_out_nbytes_ret); + +/* + * Like libdeflate_deflate_decompress(), but assumes the zlib wrapper format + * instead of raw DEFLATE. + * + * Decompression will stop at the end of the zlib stream, even if it is shorter + * than 'in_nbytes'. If you need to know exactly where the zlib stream ended, + * use libdeflate_zlib_decompress_ex(). + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_zlib_decompress(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_out_nbytes_ret); + +/* + * Like libdeflate_zlib_decompress(), but adds the 'actual_in_nbytes_ret' + * argument. If 'actual_in_nbytes_ret' is not NULL and the decompression + * succeeds (indicating that the first zlib-compressed stream in the input + * buffer was decompressed), then the actual number of input bytes consumed is + * written to *actual_in_nbytes_ret. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_zlib_decompress_ex(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, + size_t *actual_out_nbytes_ret); + +/* + * Like libdeflate_deflate_decompress(), but assumes the gzip wrapper format + * instead of raw DEFLATE. + * + * If multiple gzip-compressed members are concatenated, then only the first + * will be decompressed. Use libdeflate_gzip_decompress_ex() if you need + * multi-member support. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_gzip_decompress(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_out_nbytes_ret); + +/* + * Like libdeflate_gzip_decompress(), but adds the 'actual_in_nbytes_ret' + * argument. If 'actual_in_nbytes_ret' is not NULL and the decompression + * succeeds (indicating that the first gzip-compressed member in the input + * buffer was decompressed), then the actual number of input bytes consumed is + * written to *actual_in_nbytes_ret. + */ +LIBDEFLATEAPI enum libdeflate_result +libdeflate_gzip_decompress_ex(struct libdeflate_decompressor *decompressor, + const void *in, size_t in_nbytes, + void *out, size_t out_nbytes_avail, + size_t *actual_in_nbytes_ret, + size_t *actual_out_nbytes_ret); + +/* + * libdeflate_free_decompressor() frees a decompressor that was allocated with + * libdeflate_alloc_decompressor(). If a NULL pointer is passed in, no action + * is taken. + */ +LIBDEFLATEAPI void +libdeflate_free_decompressor(struct libdeflate_decompressor *decompressor); + +/* ========================================================================== */ +/* Checksums */ +/* ========================================================================== */ + +/* + * libdeflate_adler32() updates a running Adler-32 checksum with 'len' bytes of + * data and returns the updated checksum. When starting a new checksum, the + * required initial value for 'adler' is 1. This value is also returned when + * 'buffer' is specified as NULL. + */ +LIBDEFLATEAPI uint32_t +libdeflate_adler32(uint32_t adler, const void *buffer, size_t len); + + +/* + * libdeflate_crc32() updates a running CRC-32 checksum with 'len' bytes of data + * and returns the updated checksum. When starting a new checksum, the required + * initial value for 'crc' is 0. This value is also returned when 'buffer' is + * specified as NULL. + */ +LIBDEFLATEAPI uint32_t +libdeflate_crc32(uint32_t crc, const void *buffer, size_t len); + +/* ========================================================================== */ +/* Custom memory allocator */ +/* ========================================================================== */ + +/* + * Install a custom memory allocator which libdeflate will use for all memory + * allocations by default. 'malloc_func' is a function that must behave like + * malloc(), and 'free_func' is a function that must behave like free(). + * + * The per-(de)compressor custom memory allocator that can be specified in + * 'struct libdeflate_options' takes priority over this. + * + * This doesn't affect the free() function that will be used to free + * (de)compressors that were already in existence when this is called. + */ +LIBDEFLATEAPI void +libdeflate_set_memory_allocator(void *(*malloc_func)(size_t), + void (*free_func)(void *)); + +/* + * Advanced options. This is the options structure that + * libdeflate_alloc_compressor_ex() and libdeflate_alloc_decompressor_ex() + * require. Most users won't need this and should just use the non-"_ex" + * functions instead. If you do need this, it should be initialized like this: + * + * struct libdeflate_options options; + * + * memset(&options, 0, sizeof(options)); + * options.sizeof_options = sizeof(options); + * // Then set the fields that you need to override the defaults for. + */ +struct libdeflate_options { + + /* + * This field must be set to the struct size. This field exists for + * extensibility, so that fields can be appended to this struct in + * future versions of libdeflate while still supporting old binaries. + */ + size_t sizeof_options; + + /* + * An optional custom memory allocator to use for this (de)compressor. + * 'malloc_func' must be a function that behaves like malloc(), and + * 'free_func' must be a function that behaves like free(). + * + * This is useful in cases where a process might have multiple users of + * libdeflate who want to use different memory allocators. For example, + * a library might want to use libdeflate with a custom memory allocator + * without interfering with user code that might use libdeflate too. + * + * This takes priority over the "global" memory allocator (which by + * default is malloc() and free(), but can be changed by + * libdeflate_set_memory_allocator()). Moreover, libdeflate will never + * call the "global" memory allocator if a per-(de)compressor custom + * allocator is always given. + */ + void *(*malloc_func)(size_t); + void (*free_func)(void *); +}; + +#ifdef __cplusplus +} +#endif + +#endif /* LIBDEFLATE_H */ diff --git a/Plugins/nosGeometry/External/openFBX/ofbx.cpp b/Plugins/nosGeometry/External/openFBX/ofbx.cpp new file mode 100644 index 00000000..f14444e6 --- /dev/null +++ b/Plugins/nosGeometry/External/openFBX/ofbx.cpp @@ -0,0 +1,4102 @@ +#include "ofbx.h" +#include "libdeflate.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __cplusplus >= 202002L +#include // for std::bit_cast (C++20 and later) +#endif +#include + +namespace ofbx +{ + +static int decodeIndex(int idx) +{ + return (idx < 0) ? (-idx - 1) : idx; +} + +static int codeIndex(int idx, bool last) +{ + return last ? (-idx - 1) : idx; +} + +template +static T& emplace_back(std::vector& vec) { + vec.emplace_back(); + return vec.back(); +} + +struct Allocator { + struct Page { + struct { + Page* next = nullptr; + u32 offset = 0; + } header; + u8 data[4096 * 1024 - 12]; + }; + Page* first = nullptr; + + ~Allocator() { + Page* p = first; + while (p) { + Page* n = p->header.next; + delete p; + p = n; + } + } + + template T* allocate(Args&&... args) + { + assert(sizeof(T) <= sizeof(first->data)); + if (!first) { + first = new Page; + } + Page* p = first; + if (p->header.offset % alignof(T) != 0) { + p->header.offset += alignof(T) - p->header.offset % alignof(T); + } + + if (p->header.offset + sizeof(T) > sizeof(p->data)) { + p = new Page; + p->header.next = first; + first = p; + } + T* res = new (p->data + p->header.offset) T(args...); + p->header.offset += sizeof(T); + return res; + } +}; + + +struct Video +{ + IElementProperty* base64_property = nullptr; + DataView filename; + DataView content; + DataView media; + bool is_base_64; +}; + + +struct Error +{ + Error() {} + Error(const char* msg) + { + s_message = msg; + } + + // Format a message with printf-style arguments. + template + Error(const char* fmt, Args... args) + { + char buf[1024]; + std::snprintf(buf, sizeof(buf), fmt, args...); + s_message = buf; + } + + static const char* s_message; +}; + + +const char* Error::s_message = ""; + + +template struct OptionalError +{ + OptionalError(Error error) + : is_error(true) + { + } + + + OptionalError(T _value) + : value(_value) + , is_error(false) + { + } + + + T getValue() const + { +#ifdef _DEBUG + assert(error_checked); +#endif + return value; + } + + + bool isError() + { +#ifdef _DEBUG + error_checked = true; +#endif + return is_error; + } + + +private: + T value; + bool is_error; +#ifdef _DEBUG + bool error_checked = false; +#endif +}; + + +#pragma pack(1) +struct Header +{ + u8 magic[21]; + u8 reserved[2]; + u32 version; +}; +#pragma pack() + + +struct Cursor +{ + const u8* current; + const u8* begin; + const u8* end; +}; + + +static void setTranslation(const DVec3& t, DMatrix* mtx) +{ + mtx->m[12] = t.x; + mtx->m[13] = t.y; + mtx->m[14] = t.z; +} + + +static DVec3 operator-(const DVec3& v) +{ + return {-v.x, -v.y, -v.z}; +} + + +static DMatrix operator*(const DMatrix& lhs, const DMatrix& rhs) +{ + DMatrix res; + for (int j = 0; j < 4; ++j) + { + for (int i = 0; i < 4; ++i) + { + double tmp = 0; + for (int k = 0; k < 4; ++k) + { + tmp += lhs.m[i + k * 4] * rhs.m[k + j * 4]; + } + res.m[i + j * 4] = tmp; + } + } + return res; +} + + +static DMatrix makeIdentity() +{ + return {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; +} + + +static DMatrix rotationX(double angle) +{ + DMatrix m = makeIdentity(); + double c = cos(angle); + double s = sin(angle); + + m.m[5] = m.m[10] = c; + m.m[9] = -s; + m.m[6] = s; + + return m; +} + + +static DMatrix rotationY(double angle) +{ + DMatrix m = makeIdentity(); + double c = cos(angle); + double s = sin(angle); + + m.m[0] = m.m[10] = c; + m.m[8] = s; + m.m[2] = -s; + + return m; +} + + +static DMatrix rotationZ(double angle) +{ + DMatrix m = makeIdentity(); + double c = cos(angle); + double s = sin(angle); + + m.m[0] = m.m[5] = c; + m.m[4] = -s; + m.m[1] = s; + + return m; +} + + +static DMatrix getRotationMatrix(const DVec3& euler, RotationOrder order) +{ + const double TO_RAD = 3.1415926535897932384626433832795028 / 180.0; + DMatrix rx = rotationX(euler.x * TO_RAD); + DMatrix ry = rotationY(euler.y * TO_RAD); + DMatrix rz = rotationZ(euler.z * TO_RAD); + switch (order) + { + default: + case RotationOrder::EULER_XYZ: return rz * ry * rx; + case RotationOrder::EULER_XZY: return ry * rz * rx; + case RotationOrder::EULER_YXZ: return rz * rx * ry; + case RotationOrder::EULER_YZX: return rx * rz * ry; + case RotationOrder::EULER_ZXY: return ry * rx * rz; + case RotationOrder::EULER_ZYX: return rx * ry * rz; + case RotationOrder::SPHERIC_XYZ: assert(false); Error::s_message = "Unsupported rotation order."; return rx * ry * rz; + } +} + + +double fbxTimeToSeconds(i64 value) +{ + return double(value) / 46186158000L; +} + + +i64 secondsToFbxTime(double value) +{ + return i64(value * 46186158000L); +} + + +static DVec3 operator*(const DVec3& v, float f) +{ + return {v.x * f, v.y * f, v.z * f}; +} + + +static DVec3 operator+(const DVec3& a, const DVec3& b) +{ + return {a.x + b.x, a.y + b.y, a.z + b.z}; +} + +static FVec3 operator+(const FVec3& a, const FVec3& b) +{ + return {a.x + b.x, a.y + b.y, a.z + b.z}; +} + + +template static bool copyString(char (&destination)[SIZE], const char* source) +{ + const char* src = source; + char* dest = destination; + int length = SIZE; + if (!src) return false; + + while (*src && length > 1) + { + *dest = *src; + --length; + ++dest; + ++src; + } + *dest = 0; + return *src == '\0'; +} + + +u64 DataView::toU64() const +{ + if (is_binary) + { + assert(end - begin == sizeof(u64)); + u64 result; + memcpy(&result, begin, sizeof(u64)); + return result; + } + static_assert(sizeof(unsigned long long) >= sizeof(u64), "can't use strtoull"); + return strtoull((const char*)begin, nullptr, 10); +} + + +i64 DataView::toI64() const +{ + if (is_binary) + { + assert(end - begin == sizeof(i64)); + i64 result; + memcpy(&result, begin, sizeof(i64)); + return result; + } + static_assert(sizeof(long long) >= sizeof(i64), "can't use atoll"); + return atoll((const char*)begin); +} + + +int DataView::toInt() const +{ + if (is_binary) + { + assert(end - begin == sizeof(int)); + int result; + memcpy(&result, begin, sizeof(int)); + return result; + } + return atoi((const char*)begin); +} + + +u32 DataView::toU32() const +{ + if (is_binary) + { + assert(end - begin == sizeof(u32)); + u32 result; + memcpy(&result, begin, sizeof(u32)); + return result; + } + return (u32)atoll((const char*)begin); +} + +bool DataView::toBool() const +{ + return toInt() != 0; +} + + +double DataView::toDouble() const +{ + if (is_binary) + { + assert(end - begin == sizeof(double)); + double result; + memcpy(&result, begin, sizeof(double)); + return result; + } + return atof((const char*)begin); +} + + +float DataView::toFloat() const +{ + if (is_binary) + { + assert(end - begin == sizeof(float)); + float result; + memcpy(&result, begin, sizeof(float)); + return result; + } + return (float)atof((const char*)begin); +} + + +bool DataView::operator==(const char* rhs) const +{ + if (!begin) return !rhs[0]; + const char* c = rhs; + const char* c2 = (const char*)begin; + while (*c && c2 != (const char*)end) + { + if (*c != *c2) return false; + ++c; + ++c2; + } + return *c2 == '\0' || c2 == (const char*)end && *c == '\0'; +} + + +struct Property; +struct Element; + +template static bool parseMemory(const Property& property, T* out, int max_size_bytes); +template static bool parseVecData(Property& property, std::vector* out_vec); +template static bool parseVertexData(const Element& element, const char* name, const char* index_name, T& out, std::vector& jobs); +static bool parseDouble(Property& property, double* out); + +struct ParseDataJob { + using F = bool (*)(Property*, void*); + Property* property = nullptr; + void* data = nullptr; + bool error = false; + F f; +}; + +template [[nodiscard]] bool pushJob(std::vector& jobs, Property& prop, std::vector& data) { + ParseDataJob& job = emplace_back(jobs); + job.property = ∝ + job.data = (void*)&data; + job.f = [](Property* prop, void* data){ return parseVecData(*prop, (std::vector*)data); }; + return true; +} + +struct Property : IElementProperty +{ + Type getType() const override { return (Type)type; } + IElementProperty* getNext() const override { return next; } + DataView getValue() const override { return value; } + int getCount() const override + { + assert(type == ARRAY_DOUBLE || type == ARRAY_INT || type == ARRAY_FLOAT || type == ARRAY_LONG); + if (value.is_binary) + { + int i; + memcpy(&i, value.begin, sizeof(i)); + return i; + } + return count; + } + + bool getValues(double* values, int max_size) const override { return parseMemory(*this, values, max_size); } + + bool getValues(float* values, int max_size) const override { return parseMemory(*this, values, max_size); } + + bool getValues(u64* values, int max_size) const override { return parseMemory(*this, values, max_size); } + + bool getValues(i64* values, int max_size) const override { return parseMemory(*this, values, max_size); } + + bool getValues(int* values, int max_size) const override { return parseMemory(*this, values, max_size); } + + int count = 0; + u8 type = INTEGER; + DataView value; + Property* next = nullptr; +}; + +struct Element : IElement +{ + IElement* getFirstChild() const override { return child; } + IElement* getSibling() const override { return sibling; } + DataView getID() const override { return id; } + IElementProperty* getFirstProperty() const override { return first_property; } + IElementProperty* getProperty(int idx) const + { + IElementProperty* prop = first_property; + for (int i = 0; i < idx; ++i) + { + if (prop == nullptr) return nullptr; + prop = prop->getNext(); + } + return prop; + } + + DataView id; + Element* child = nullptr; + Element* sibling = nullptr; + Property* first_property = nullptr; +}; + + +static const Element* findChild(const Element& element, const char* id) +{ + Element* const* iter = &element.child; + while (*iter) + { + if ((*iter)->id == id) return *iter; + iter = &(*iter)->sibling; + } + return nullptr; +} + + +static IElement* resolveProperty(const Object& obj, const char* name, bool* is_p60) +{ + *is_p60 = false; + const Element* props = findChild((const Element&)obj.element, "Properties70"); + if (!props) { + props = findChild((const Element&)obj.element, "Properties60"); + *is_p60 = true; + if (!props) return nullptr; + } + + Element* prop = props->child; + while (prop) + { + if (prop->first_property && prop->first_property->value == name) + { + return prop; + } + prop = prop->sibling; + } + return nullptr; +} + + +static int resolveEnumProperty(const Object& object, const char* name, int default_value) +{ + bool is_p60; + Element* element = (Element*)resolveProperty(object, name, &is_p60); + if (!element) return default_value; + Property* x = (Property*)element->getProperty(is_p60 ? 3 : 4); + if (!x) return default_value; + + return x->value.toInt(); +} + + +static DVec3 resolveVec3Property(const Object& object, const char* name, const DVec3& default_value) +{ + bool is_p60; + Element* element = (Element*)resolveProperty(object, name, &is_p60); + if (!element) return default_value; + Property* x = (Property*)element->getProperty(is_p60 ? 3 : 4); + if (!x || !x->next || !x->next->next) return default_value; + + return {x->value.toDouble(), x->next->value.toDouble(), x->next->next->value.toDouble()}; +} + +static bool isString(const Property* prop) +{ + if (!prop) return false; + return prop->getType() == Property::STRING; +} + + +static bool isLong(const Property* prop) +{ + if (!prop) return false; + return prop->getType() == Property::LONG; +} + +static bool decompress(const u8* in, size_t in_size, u8* out, size_t out_size) +{ + auto dec = libdeflate_alloc_decompressor(); + size_t dummy; + bool res = libdeflate_deflate_decompress(dec, in + 2, in_size - 2, out, out_size, &dummy) == LIBDEFLATE_SUCCESS; + libdeflate_free_decompressor(dec); + return res; +} + + +template static OptionalError read(Cursor* cursor) +{ + if (cursor->current + sizeof(T) > cursor->end) return Error("Reading past the end"); + T value = *(const T*)cursor->current; + cursor->current += sizeof(T); + return value; +} + + +static OptionalError readShortString(Cursor* cursor) +{ + DataView value; + OptionalError length = read(cursor); + if (length.isError()) return Error(); + + if (cursor->current + length.getValue() > cursor->end) return Error("Reading past the end"); + value.begin = cursor->current; + cursor->current += length.getValue(); + + value.end = cursor->current; + + return value; +} + + +static OptionalError readLongString(Cursor* cursor) +{ + DataView value; + OptionalError length = read(cursor); + if (length.isError()) return Error(); + + if (cursor->current + length.getValue() > cursor->end) return Error("Reading past the end"); + value.begin = cursor->current; + cursor->current += length.getValue(); + + value.end = cursor->current; + + return value; +} + +// Cheat sheet: // +/* +'S': Long string +'Y': 16-bit signed integer +'C': 8-bit signed integer +'I': 32-bit signed integer +'F': Single precision floating-point number +'D': Double precision floating-point number +'L': 64-bit signed integer +'R': Binary data +'b', 'f', 'd', 'l', 'c' and 'i': Arrays of binary data + +Src: https://code.blender.org/2013/08/fbx-binary-file-format-specification/ +*/ + +static OptionalError readProperty(Cursor* cursor, Allocator& allocator) +{ + if (cursor->current == cursor->end) return Error("Reading past the end"); + + Property* prop = allocator.allocate(); + prop->next = nullptr; + prop->type = *cursor->current; + ++cursor->current; + prop->value.begin = cursor->current; + + switch (prop->type) + { + case 'S': + { + OptionalError val = readLongString(cursor); + if (val.isError()) return Error(); + prop->value = val.getValue(); + break; + } + case 'Y': cursor->current += 2; break; + case 'C': cursor->current += 1; break; + case 'I': cursor->current += 4; break; + case 'F': cursor->current += 4; break; + case 'D': cursor->current += 8; break; + case 'L': cursor->current += 8; break; + case 'R': + { + OptionalError len = read(cursor); + if (len.isError()) return Error(); + if (cursor->current + len.getValue() > cursor->end) return Error("Reading past the end"); + cursor->current += len.getValue(); + break; + } + case 'b': + case 'c': + case 'f': + case 'd': + case 'l': + case 'i': + { + OptionalError length = read(cursor); + OptionalError encoding = read(cursor); + OptionalError comp_len = read(cursor); + if (length.isError() || encoding.isError() || comp_len.isError()) return Error(); + if (cursor->current + comp_len.getValue() > cursor->end) return Error("Reading past the end"); + cursor->current += comp_len.getValue(); + break; + } + default: + { + char str[32]; + snprintf(str, sizeof(str), "Unknown property type: %c", prop->type); + return Error(str); + } + } + prop->value.end = cursor->current; + return prop; +} + +static OptionalError readElementOffset(Cursor* cursor, u32 version) +{ + if (version >= 7500) + { + OptionalError tmp = read(cursor); + if (tmp.isError()) return Error(); + return tmp.getValue(); + } + + OptionalError tmp = read(cursor); + if (tmp.isError()) return Error(); + return tmp.getValue(); +} + + +static OptionalError readElement(Cursor* cursor, u32 version, Allocator& allocator) +{ + OptionalError end_offset = readElementOffset(cursor, version); + if (end_offset.isError()) return Error(); + if (end_offset.getValue() == 0) return nullptr; + + OptionalError prop_count = readElementOffset(cursor, version); + OptionalError prop_length = readElementOffset(cursor, version); + if (prop_count.isError() || prop_length.isError()) return Error(); + + OptionalError id = readShortString(cursor); + if (id.isError()) return Error(); + + Element* element = allocator.allocate(); + element->first_property = nullptr; + element->id = id.getValue(); + + element->child = nullptr; + element->sibling = nullptr; + + Property** prop_link = &element->first_property; + for (u32 i = 0; i < prop_count.getValue(); ++i) + { + OptionalError prop = readProperty(cursor, allocator); + if (prop.isError()) + { + return Error(); + } + + *prop_link = prop.getValue(); + prop_link = &(*prop_link)->next; + } + + if (cursor->current - cursor->begin >= (ptrdiff_t)end_offset.getValue()) return element; + + int BLOCK_SENTINEL_LENGTH = version >= 7500 ? 25 : 13; + + Element** link = &element->child; + while (cursor->current - cursor->begin < ((ptrdiff_t)end_offset.getValue() - BLOCK_SENTINEL_LENGTH)) + { + OptionalError child = readElement(cursor, version, allocator); + if (child.isError()) + { + return Error(); + } + + *link = child.getValue(); + if (child.getValue() == 0) break; + link = &(*link)->sibling; + } + + if (cursor->current + BLOCK_SENTINEL_LENGTH > cursor->end) + { + return Error("Reading past the end"); + } + + cursor->current += BLOCK_SENTINEL_LENGTH; + return element; +} + + +static bool isEndLine(const Cursor& cursor) +{ + return *cursor.current == '\n' || *cursor.current == '\r' && cursor.current + 1 < cursor.end && *(cursor.current + 1) != '\n'; +} + + +static void skipInsignificantWhitespaces(Cursor* cursor) +{ + while (cursor->current < cursor->end && isspace(*cursor->current) && !isEndLine(*cursor)) + { + ++cursor->current; + } +} + + +static void skipLine(Cursor* cursor) +{ + while (cursor->current < cursor->end && !isEndLine(*cursor)) + { + ++cursor->current; + } + if (cursor->current < cursor->end) ++cursor->current; + skipInsignificantWhitespaces(cursor); +} + + +static void skipWhitespaces(Cursor* cursor) +{ + while (cursor->current < cursor->end && isspace(*cursor->current)) + { + ++cursor->current; + } + while (cursor->current < cursor->end && *cursor->current == ';') skipLine(cursor); +} + + +static bool isTextTokenChar(char c) +{ + return isalnum(c) || c == '_' || c == '-'; +} + + +static DataView readTextToken(Cursor* cursor) +{ + DataView ret; + ret.begin = cursor->current; + while (cursor->current < cursor->end && isTextTokenChar(*cursor->current)) + { + ++cursor->current; + } + ret.end = cursor->current; + return ret; +} + + +static OptionalError readTextProperty(Cursor* cursor, Allocator& allocator) +{ + Property* prop = allocator.allocate(); + prop->value.is_binary = false; + prop->next = nullptr; + if (*cursor->current == '"') + { + prop->type = 'S'; + ++cursor->current; + prop->value.begin = cursor->current; + while (cursor->current < cursor->end && *cursor->current != '"') + { + ++cursor->current; + } + prop->value.end = cursor->current; + if (cursor->current < cursor->end) ++cursor->current; // skip '"' + return prop; + } + + if (isdigit(*cursor->current) || *cursor->current == '-') + { + prop->type = 'L'; + prop->value.begin = cursor->current; + if (*cursor->current == '-') ++cursor->current; + while (cursor->current < cursor->end && isdigit(*cursor->current)) + { + ++cursor->current; + } + prop->value.end = cursor->current; + + if (cursor->current < cursor->end && *cursor->current == '.') + { + prop->type = 'D'; + ++cursor->current; + while (cursor->current < cursor->end && isdigit(*cursor->current)) + { + ++cursor->current; + } + if (cursor->current < cursor->end && (*cursor->current == 'e' || *cursor->current == 'E')) + { + // 10.5e-013 + ++cursor->current; + if (cursor->current < cursor->end && *cursor->current == '-') ++cursor->current; + while (cursor->current < cursor->end && isdigit(*cursor->current)) ++cursor->current; + } + + + prop->value.end = cursor->current; + } + else if (cursor->current < cursor->end && (*cursor->current == 'e' || *cursor->current == 'E')) { + prop->type = 'D'; + // 10e-013 + ++cursor->current; + if (cursor->current < cursor->end && *cursor->current == '-') ++cursor->current; + while (cursor->current < cursor->end && isdigit(*cursor->current)) ++cursor->current; + prop->value.end = cursor->current; + } + return prop; + } + + if (*cursor->current == 'T' || *cursor->current == 'Y' || *cursor->current == 'W' || *cursor->current == 'C') + { + // WTF is this + prop->type = *cursor->current; + prop->value.begin = cursor->current; + ++cursor->current; + prop->value.end = cursor->current; + return prop; + } + + if (*cursor->current == ',') { + // https://github.com/nem0/OpenFBX/issues/85 + prop->type = IElementProperty::NONE; + prop->value.begin = cursor->current; + prop->value.end = cursor->current; + return prop; + } + + if (*cursor->current == '*') + { + prop->type = 'l'; + ++cursor->current; + // Vertices: *10740 { a: 14.2760353088379,... } + while (cursor->current < cursor->end && *cursor->current != ':') + { + ++cursor->current; + } + if (cursor->current < cursor->end) ++cursor->current; // skip ':' + skipInsignificantWhitespaces(cursor); + prop->value.begin = cursor->current; + prop->count = 0; + bool is_any = false; + while (cursor->current < cursor->end && *cursor->current != '}') + { + if (*cursor->current == ',') + { + if (is_any) ++prop->count; + is_any = false; + } + else if (!isspace(*cursor->current) && !isEndLine(*cursor)) + is_any = true; + if (*cursor->current == '.') prop->type = 'd'; + ++cursor->current; + } + if (is_any) ++prop->count; + prop->value.end = cursor->current; + if (cursor->current < cursor->end) ++cursor->current; // skip '}' + return prop; + } + + assert(false); + return Error("Unknown error"); +} + + +static OptionalError readTextElement(Cursor* cursor, Allocator& allocator) +{ + DataView id = readTextToken(cursor); + if (cursor->current == cursor->end) return Error("Unexpected end of file"); + if (*cursor->current != ':') return Error("Unexpected character"); + ++cursor->current; + + skipInsignificantWhitespaces(cursor); + if (cursor->current == cursor->end) return Error("Unexpected end of file"); + + Element* element = allocator.allocate(); + element->id = id; + + Property** prop_link = &element->first_property; + while (cursor->current < cursor->end && !isEndLine(*cursor) && *cursor->current != '{') + { + OptionalError prop = readTextProperty(cursor, allocator); + if (prop.isError()) + { + return Error(); + } + if (cursor->current < cursor->end && *cursor->current == ',') + { + ++cursor->current; + skipWhitespaces(cursor); + } + skipInsignificantWhitespaces(cursor); + + *prop_link = prop.getValue(); + prop_link = &(*prop_link)->next; + } + + Element** link = &element->child; + if (*cursor->current == '{') + { + ++cursor->current; + skipWhitespaces(cursor); + while (cursor->current < cursor->end && *cursor->current != '}') + { + OptionalError child = readTextElement(cursor, allocator); + if (child.isError()) + { + return Error(); + } + skipWhitespaces(cursor); + + *link = child.getValue(); + link = &(*link)->sibling; + } + if (cursor->current < cursor->end) ++cursor->current; // skip '}' + } + return element; +} + + +static OptionalError tokenizeText(const u8* data, size_t size, Allocator& allocator) +{ + Cursor cursor; + cursor.begin = data; + cursor.current = data; + cursor.end = data + size; + + Element* root = allocator.allocate(); + root->first_property = nullptr; + root->id.begin = nullptr; + root->id.end = nullptr; + root->child = nullptr; + root->sibling = nullptr; + + Element** element = &root->child; + while (cursor.current < cursor.end) + { + if (*cursor.current == ';' || *cursor.current == '\r' || *cursor.current == '\n') + { + skipLine(&cursor); + } + else + { + OptionalError child = readTextElement(&cursor, allocator); + if (child.isError()) + { + return Error(); + } + *element = child.getValue(); + if (!*element) return root; + element = &(*element)->sibling; + } + } + + return root; +} + + +static OptionalError tokenize(const u8* data, size_t size, u32& version, Allocator& allocator) { + if (size < sizeof(Header)) return Error("Invalid header"); + + Cursor cursor; + cursor.begin = data; + cursor.current = data; + cursor.end = data + size; + +#if __cplusplus >= 202002L + const Header* header = std::bit_cast(cursor.current); +#else + Header header_temp; + memcpy(&header_temp, cursor.current, sizeof(Header)); + const Header* header = &header_temp; +#endif + + cursor.current += sizeof(Header); + version = header->version; + + Element* root = allocator.allocate(); + root->first_property = nullptr; + root->id.begin = nullptr; + root->id.end = nullptr; + root->child = nullptr; + root->sibling = nullptr; + + Element** element = &root->child; + for (;;) + { + OptionalError child = readElement(&cursor, header->version, allocator); + if (child.isError()) + { + return Error(); + } + + *element = child.getValue(); + if (!*element) return root; + element = &(*element)->sibling; + } +} + +static void parseTemplates(const Element& root) +{ + const Element* defs = findChild(root, "Definitions"); + if (!defs) return; + + std::unordered_map templates; + Element* def = defs->child; + while (def) + { + if (def->id == "ObjectType") + { + Element* subdef = def->child; + while (subdef) + { + if (subdef->id == "PropertyTemplate") + { + DataView prop1 = def->first_property->value; + DataView prop2 = subdef->first_property->value; + std::string key((const char*)prop1.begin, prop1.end - prop1.begin); + key += std::string((const char*)prop1.begin, prop1.end - prop1.begin); + templates[key] = subdef; + } + subdef = subdef->sibling; + } + } + def = def->sibling; + } + // TODO +} + + +struct Scene; + +enum class VertexDataMapping { + BY_POLYGON_VERTEX, + BY_POLYGON, + BY_VERTEX +}; + +struct Vec2AttributesImpl { + std::vector values; + std::vector indices; + VertexDataMapping mapping; + operator Vec2Attributes() const { + return { values.data(), indices.data(), int(indices.empty() ? values.size() : indices.size()) }; + } +}; + +struct Vec3AttributesImpl { + std::vector values; + std::vector indices; + VertexDataMapping mapping; + operator Vec3Attributes() const { + return { values.data(), indices.data(), int(indices.empty() ? values.size() : indices.size()), int(values.size()) }; + } +}; + +struct Vec4AttributesImpl { + std::vector values; + std::vector indices; + VertexDataMapping mapping; + operator Vec4Attributes() const { + return { values.data(), indices.data(), int(indices.empty() ? values.size() : indices.size()) }; + } +}; + +struct GeometryPartitionImpl { + std::vector polygons; + int max_polygon_triangles = 0; + int triangles_count = 0; +}; + +struct GeometryDataImpl : GeometryData { + Vec3AttributesImpl positions; + Vec3AttributesImpl normals; + Vec3AttributesImpl tangents; + Vec4AttributesImpl colors; + Vec2AttributesImpl uvs[Geometry::s_uvs_max]; + std::vector partitions; + + std::vector materials; + + template + T patchAttributes(const S& attr) const { + T res = attr; + if (!attr.values.empty() && attr.mapping == VertexDataMapping::BY_VERTEX && attr.indices.empty()) { + res.indices = positions.indices.data(); + } + return res; + } + + Vec3Attributes getPositions() const override { return positions; } + Vec3Attributes getNormals() const override { return patchAttributes(normals); } + Vec2Attributes getUVs(int index) const override { return patchAttributes(uvs[index]); } + Vec4Attributes getColors() const override { return patchAttributes(colors); } + Vec3Attributes getTangents() const override { return patchAttributes(tangents); } + int getPartitionCount() const override { return (int)partitions.size(); } + + GeometryPartition getPartition(int index) const override { + if (index >= partitions.size()) return {nullptr, 0, 0, 0}; + return { + partitions[index].polygons.data(), + int(partitions[index].polygons.size()), + partitions[index].max_polygon_triangles, + partitions[index].triangles_count + }; + } + + template + bool postprocess(T& attr) { + if (attr.values.empty()) return true; + if (attr.mapping == VertexDataMapping::BY_VERTEX && !attr.indices.empty()) { + if (positions.indices.empty()) return false; // not supported + + std::vector remapped; + attr.mapping = VertexDataMapping::BY_POLYGON_VERTEX; + remapped.resize(positions.indices.size()); + for (int i = 0; i < remapped.size(); ++i) { + remapped[i] = attr.indices[decodeIndex(positions.indices[i])]; + } + attr.indices = remapped; + } + else if (attr.mapping == VertexDataMapping::BY_POLYGON) { + if (!attr.indices.empty()) return false; // not supported + if (partitions.size() != 1) return false; // not supported + if (partitions[0].polygons.size() != attr.values.size()) return false; // invalid + + std::vector remapped; + attr.mapping = VertexDataMapping::BY_POLYGON_VERTEX; + remapped.resize(positions.indices.size()); + + for (int i = 0, c = (int)partitions[0].polygons.size(); i < c; ++i) { + GeometryPartition::Polygon& polygon = partitions[0].polygons[i]; + for (int j = polygon.from_vertex; j < polygon.from_vertex + polygon.vertex_count; ++j) { + remapped[j] = i; + } + } + attr.indices = remapped; + } + return true; + } + + bool postprocess() { + if (materials.empty()) { + GeometryPartitionImpl& partition = emplace_back(partitions); + int polygon_count = 0; + for (int i : positions.indices) { + if (i < 0) ++polygon_count; + } + partition.polygons.reserve(polygon_count); + int polygon_start = 0; + int max_polygon_triangles = 0; + int total_triangles = 0; + int* indices = positions.indices.data(); + for (int i = 0, c = (int)positions.indices.size(); i < c; ++i) { + if (indices[i] < 0) { + int vertex_count = i - polygon_start + 1; + if (vertex_count > 2) { + partition.polygons.push_back({polygon_start, vertex_count}); + indices[i] = -indices[i] - 1; + int triangles = vertex_count - 2; + total_triangles += triangles; + if (triangles > max_polygon_triangles) max_polygon_triangles = triangles; + } + polygon_start = i + 1; + } + } + partition.max_polygon_triangles = max_polygon_triangles; + partition.triangles_count = total_triangles; + } + else { + int max_partition = 0; + for (int m : materials) { + if (m > max_partition) max_partition = m; + } + partitions.resize(max_partition + 1); + + u32 polygon_idx = 0; + int* indices = positions.indices.data(); + int num_polygon_vertices = 0; + int polygon_start = 0; + for (int i = 0, c = (int)positions.indices.size(); i < c; ++i) { + ++num_polygon_vertices; + if (indices[i] < 0) { + u32 material_index = materials[polygon_idx]; + GeometryPartitionImpl& partition = partitions[material_index]; + partition.polygons.push_back({polygon_start, num_polygon_vertices}); + + int triangles = num_polygon_vertices - 2; + partition.triangles_count += triangles; + if (triangles > partition.max_polygon_triangles) partition.max_polygon_triangles = triangles; + + indices[i] = -indices[i] - 1; + + polygon_start = i + 1; + ++polygon_idx; + num_polygon_vertices = 0; + } + } + } + + postprocess(normals); + postprocess(tangents); + for (Vec2AttributesImpl& uv : uvs) postprocess(uv); + postprocess(colors); + + return true; + } +}; + + +Mesh::Mesh(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + +struct GeometryImpl : Geometry, GeometryDataImpl { + const Skin* skin = nullptr; + const BlendShape* blendShape = nullptr; + + GeometryImpl(const Scene& _scene, const IElement& _element) + : Geometry(_scene, _element) + { + } + + Type getType() const override { return Type::GEOMETRY; } + const GeometryData& getGeometryData() const override { return *this; } + const Skin* getSkin() const override { return skin; } + const BlendShape* getBlendShape() const override { return blendShape; } +}; + +struct MeshImpl : Mesh +{ + MeshImpl(const Scene& _scene, const IElement& _element) + : Mesh(_scene, _element) + { + is_node = true; + } + + + DMatrix getGeometricMatrix() const override + { + DVec3 translation = resolveVec3Property(*this, "GeometricTranslation", {0, 0, 0}); + DVec3 rotation = resolveVec3Property(*this, "GeometricRotation", {0, 0, 0}); + DVec3 scale = resolveVec3Property(*this, "GeometricScaling", {1, 1, 1}); + + DMatrix scale_mtx = makeIdentity(); + scale_mtx.m[0] = (float)scale.x; + scale_mtx.m[5] = (float)scale.y; + scale_mtx.m[10] = (float)scale.z; + DMatrix mtx = getRotationMatrix(rotation, RotationOrder::EULER_XYZ); + setTranslation(translation, &mtx); + + return scale_mtx * mtx; + } + + Type getType() const override { return Type::MESH; } + + const Pose* getPose() const override { return pose; } + const Geometry* getGeometry() const override { return geometry; } + const Material* getMaterial(int index) const override { return materials[index]; } + int getMaterialCount() const override { return (int)materials.size(); } + + const GeometryData& getGeometryData() const override { return geometry ? static_cast(*geometry) : geometry_data; } + const Skin* getSkin() const override { return geometry ? geometry->getSkin() : skin; } + const BlendShape* getBlendShape() const override { return geometry ? geometry->getBlendShape() : blendShape; } + + const Pose* pose = nullptr; + const GeometryImpl* geometry = nullptr; + std::vector materials; + const Skin* skin = nullptr; + const BlendShape* blendShape = nullptr; + + // old formats do not use Geometry nodes but embed vertex data directly in Mesh + GeometryDataImpl geometry_data; +}; + + +Material::Material(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct MaterialImpl : Material +{ + MaterialImpl(const Scene& _scene, const IElement& _element) + : Material(_scene, _element) + { + for (const Texture*& tex : textures) tex = nullptr; + } + + Type getType() const override { return Type::MATERIAL; } + + const Texture* getTexture(Texture::TextureType type) const override { return textures[type]; } + Color getDiffuseColor() const override { return diffuse_color; } + Color getSpecularColor() const override { return specular_color; } + Color getReflectionColor() const override { return reflection_color; }; + Color getAmbientColor() const override { return ambient_color; }; + Color getEmissiveColor() const override { return emissive_color; }; + + double getDiffuseFactor() const override { return diffuse_factor; }; + double getSpecularFactor() const override { return specular_factor; }; + double getReflectionFactor() const override { return reflection_factor; }; + double getShininess() const override { return shininess; }; + double getShininessExponent() const override { return shininess_exponent; }; + double getAmbientFactor() const override { return ambient_factor; }; + double getBumpFactor() const override { return bump_factor; }; + double getEmissiveFactor() const override { return emissive_factor; }; + + const Texture* textures[Texture::TextureType::COUNT]; + Color diffuse_color; + Color specular_color; + Color reflection_color; + Color ambient_color; + Color emissive_color; + + double diffuse_factor; + double specular_factor; + double reflection_factor; + double shininess; + double shininess_exponent; + double ambient_factor; + double bump_factor; + double emissive_factor; + }; + + +struct LimbNodeImpl : Object +{ + LimbNodeImpl(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) + { + is_node = true; + } + Type getType() const override { return Type::LIMB_NODE; } +}; + + +struct NullImpl : Object +{ + NullImpl(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) + { + is_node = true; + } + Type getType() const override { return Type::NULL_NODE; } +}; + + +NodeAttribute::NodeAttribute(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct NodeAttributeImpl : NodeAttribute +{ + NodeAttributeImpl(const Scene& _scene, const IElement& _element) + : NodeAttribute(_scene, _element) + { + } + Type getType() const override { return Type::NODE_ATTRIBUTE; } + DataView getAttributeType() const override { return attribute_type; } + + + DataView attribute_type; +}; + + +Geometry::Geometry(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +Shape::Shape(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct ShapeImpl : Shape { + std::vector vertices; + std::vector normals; + std::vector indices; + + ShapeImpl(const Scene& _scene, const IElement& _element) + : Shape(_scene, _element) + {} + + bool postprocess(GeometryImpl& geom, Allocator& allocator); + + Type getType() const override { return Type::SHAPE; } + int getVertexCount() const override { return (int)vertices.size(); } + int getIndexCount() const override { return (int)indices.size(); } + const Vec3* getVertices() const override { return &vertices[0]; } + const Vec3* getNormals() const override { return normals.empty() ? nullptr : &normals[0]; } + const int* getIndices() const override { return indices.empty() ? nullptr : &indices[0]; } +}; + + +Cluster::Cluster(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct ClusterImpl : Cluster +{ + ClusterImpl(const Scene& _scene, const IElement& _element) + : Cluster(_scene, _element) + { + } + + const int* getIndices() const override { return &indices[0]; } + int getIndicesCount() const override { return (int)indices.size(); } + const double* getWeights() const override { return &weights[0]; } + int getWeightsCount() const override { return (int)weights.size(); } + DMatrix getTransformMatrix() const override { return transform_matrix; } + DMatrix getTransformLinkMatrix() const override { return transform_link_matrix; } + Object* getLink() const override { return link; } + + bool postprocess() { + assert(skin); + + GeometryDataImpl* geom = static_cast(static_cast(skin->resolveObjectLinkReverse(Object::Type::GEOMETRY))); + if (!geom) { + MeshImpl* mesh = (MeshImpl*)skin->resolveObjectLinkReverse(Object::Type::MESH); + if(!mesh) return false; + geom = &mesh->geometry_data; + } + + const Element* indexes = findChild((const Element&)element, "Indexes"); + if (indexes && indexes->first_property) + { + if (!parseVecData(*indexes->first_property, &indices)) return false; + } + + const Element* weights_el = findChild((const Element&)element, "Weights"); + if (weights_el && weights_el->first_property) + { + if (!parseVecData(*weights_el->first_property, &weights)) return false; + } + + return true; + } + + + Object* link = nullptr; + Skin* skin = nullptr; + std::vector indices; + std::vector weights; + DMatrix transform_matrix; + DMatrix transform_link_matrix; + Type getType() const override { return Type::CLUSTER; } +}; + + +AnimationStack::AnimationStack(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +AnimationLayer::AnimationLayer(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +AnimationCurve::AnimationCurve(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +AnimationCurveNode::AnimationCurveNode(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct AnimationStackImpl : AnimationStack +{ + AnimationStackImpl(const Scene& _scene, const IElement& _element) + : AnimationStack(_scene, _element) + { + } + + + const AnimationLayer* getLayer(int index) const override + { + return resolveObjectLink(index); + } + + + Type getType() const override { return Type::ANIMATION_STACK; } +}; + + +struct AnimationCurveImpl : AnimationCurve +{ + AnimationCurveImpl(const Scene& _scene, const IElement& _element) + : AnimationCurve(_scene, _element) + { + } + + int getKeyCount() const override { return (int)times.size(); } + const i64* getKeyTime() const override { return ×[0]; } + const float* getKeyValue() const override { return &values[0]; } + + std::vector times; + std::vector values; + Type getType() const override { return Type::ANIMATION_CURVE; } +}; + + +Skin::Skin(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct SkinImpl : Skin +{ + SkinImpl(const Scene& _scene, const IElement& _element) + : Skin(_scene, _element) + { + } + + int getClusterCount() const override { return (int)clusters.size(); } + const Cluster* getCluster(int idx) const override { return clusters[idx]; } + + Type getType() const override { return Type::SKIN; } + + std::vector clusters; +}; + + +BlendShapeChannel::BlendShapeChannel(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct BlendShapeChannelImpl : BlendShapeChannel +{ + BlendShapeChannelImpl(const Scene& _scene, const IElement& _element) + : BlendShapeChannel(_scene, _element) + { + } + + double getDeformPercent() const override { return deformPercent; } + int getShapeCount() const override { return (int)shapes.size(); } + const Shape* getShape(int idx) const override { return shapes[idx]; } + + Type getType() const override { return Type::BLEND_SHAPE_CHANNEL; } + + bool postprocess(Allocator& allocator) { + assert(blendShape); + + GeometryImpl* geom = (GeometryImpl*)blendShape->resolveObjectLinkReverse(Object::Type::GEOMETRY); + if (!geom) return false; + + const Element* deform_percent_el = findChild((const Element&)element, "DeformPercent"); + if (deform_percent_el && deform_percent_el->first_property) + { + if (!parseDouble(*deform_percent_el->first_property, &deformPercent)) return false; + } + + const Element* full_weights_el = findChild((const Element&)element, "FullWeights"); + if (full_weights_el && full_weights_el->first_property) + { + if (!parseVecData(*full_weights_el->first_property, &fullWeights)) return false; + } + + for (int i = 0; i < (int)shapes.size(); i++) + { + auto shape = (ShapeImpl*)shapes[i]; + if (!shape->postprocess(*geom, allocator)) return false; + } + + return true; + } + + + BlendShape* blendShape = nullptr; + double deformPercent = 0; + std::vector fullWeights; + std::vector shapes; +}; + + +BlendShape::BlendShape(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct BlendShapeImpl : BlendShape +{ + BlendShapeImpl(const Scene& _scene, const IElement& _element) + : BlendShape(_scene, _element) + { + } + + int getBlendShapeChannelCount() const override { return (int)blendShapeChannels.size(); } + const BlendShapeChannel* getBlendShapeChannel(int idx) const override { return blendShapeChannels[idx]; } + + Type getType() const override { return Type::BLEND_SHAPE; } + + std::vector blendShapeChannels; +}; + + +Texture::Texture(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +Pose::Pose(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) +{ +} + + +struct PoseImpl : Pose +{ + PoseImpl(const Scene& _scene, const IElement& _element) + : Pose(_scene, _element) + {} + + bool postprocess(Scene& scene); + DMatrix getMatrix() const override { return matrix; } + const Object* getNode() const override { return node; } + Type getType() const override { return Type::POSE; } + + DMatrix matrix; + Object* node = nullptr; + u64 node_id; +}; + + +struct TextureImpl : Texture +{ + TextureImpl(const Scene& _scene, const IElement& _element) + : Texture(_scene, _element) + { + } + + DataView getRelativeFileName() const override { return relative_filename; } + DataView getFileName() const override { return filename; } + DataView getEmbeddedData() const override; + + DataView media; + DataView filename; + DataView relative_filename; + Type getType() const override { return Type::TEXTURE; } +}; + +struct LightImpl : Light +{ + LightImpl(const Scene& _scene, const IElement& _element) + : Light(_scene, _element) + { + } + + Type getType() const override { return Type::LIGHT; } + LightType getLightType() const override { return lightType; } + + bool doesCastLight() const override { return castLight; } + + bool doesDrawVolumetricLight() const override + { + // Return the draw volumetric light property based on the stored data (WIP) + return false; + } + + bool doesDrawGroundProjection() const override + { + // Return the draw ground projection property based on the stored data (WIP) + return false; + } + + bool doesDrawFrontFacingVolumetricLight() const override + { + // Return the draw front-facing volumetric light property based on the stored data (WIP) + return false; + } + + Color getColor() const override { return color; } + double getIntensity() const override { return intensity; } + double getInnerAngle() const override { return innerAngle; } + double getOuterAngle() const override { return outerAngle; } + + double getFog() const override { return fog; } + + DecayType getDecayType() const override { return decayType; } + double getDecayStart() const override { return decayStart; } + + // Near attenuation + bool doesEnableNearAttenuation() const override { return enableNearAttenuation; } + double getNearAttenuationStart() const override { return nearAttenuationStart; } + double getNearAttenuationEnd() const override { return nearAttenuationEnd; } + + // Far attenuation + bool doesEnableFarAttenuation() const override { return enableFarAttenuation; } + double getFarAttenuationStart() const override { return farAttenuationStart; } + double getFarAttenuationEnd() const override { return farAttenuationEnd; } + + // Shadows + const Texture* getShadowTexture() const override { return shadowTexture; } + bool doesCastShadows() const override { return castShadows; } + Color getShadowColor() const override { return shadowColor; } + + // Member variables to store light properties + //------------------------------------------------------------------------- + LightType lightType = LightType::POINT; + bool castLight = true; + Color color = {1, 1, 1}; // Light color (RGB values) + double intensity = 100.0; + + double innerAngle = 0.0; + double outerAngle = 45.0; + + double fog = 50; + + DecayType decayType = DecayType::QUADRATIC; + double decayStart = 1.0; + + bool enableNearAttenuation = false; + double nearAttenuationStart = 0.0; + double nearAttenuationEnd = 0.0; + + bool enableFarAttenuation = false; + double farAttenuationStart = 0.0; + double farAttenuationEnd = 0.0; + + const Texture* shadowTexture = nullptr; + bool castShadows = true; + Color shadowColor = {0, 0, 0}; +}; + +static float OFBX_PI = 3.14159265358979323846f; +struct CameraImpl : public Camera +{ + CameraImpl(const Scene& _scene, const IElement& _element) + : Camera(_scene, _element) + { + } + + ProjectionType projectionType = ProjectionType::PERSPECTIVE; + ApertureMode apertureMode = ApertureMode::HORIZONTAL; // Used to determine the FOV + + double filmHeight = 36.0; + double filmWidth = 24.0; + + double aspectHeight = 1.0; + double aspectWidth = 1.0; + + double nearPlane = 0.1; + double farPlane = 1000.0; + bool autoComputeClipPanes = true; + + GateFit gateFit = GateFit::HORIZONTAL; + double filmAspectRatio = 1.0; + double focalLength = 50.0; + double focusDistance = 50.0; + + DVec3 backgroundColor = {0, 0, 0}; + DVec3 interestPosition = {0, 0, 0}; + + double fieldOfView = 60.0; + + Type getType() const override { return Type::CAMERA; } + ProjectionType getProjectionType() const override { return projectionType; } + ApertureMode getApertureMode() const override { return apertureMode; } + + double getFilmHeight() const override { return filmHeight; } + double getFilmWidth() const override { return filmWidth; } + + double getAspectHeight() const override { return aspectHeight; } + double getAspectWidth() const override { return aspectWidth; } + + double getNearPlane() const override { return nearPlane; } + double getFarPlane() const override { return farPlane; } + bool doesAutoComputeClipPanes() const override { return autoComputeClipPanes; } + + GateFit getGateFit() const override { return gateFit; } + double getFilmAspectRatio() const override { return filmAspectRatio; } + double getFocalLength() const override { return focalLength; } + double getFocusDistance() const override { return focusDistance; } + + DVec3 getBackgroundColor() const override { return backgroundColor; } + DVec3 getInterestPosition() const override { return interestPosition; } + + void CalculateFOV() + { + switch (apertureMode) + { + case Camera::ApertureMode::HORIZONTAL: + fieldOfView = 2.0 * atan(filmWidth / (2.0 * focalLength)) * 180.0 / OFBX_PI; + return; + case Camera::ApertureMode::VERTICAL: + fieldOfView = 2.0 * atan(filmHeight / (2.0 * focalLength)) * 180.0 / OFBX_PI; + return; + case Camera::ApertureMode::HORIZANDVERT: + fieldOfView = 2.0 * atan(sqrt(filmWidth * filmWidth + filmHeight * filmHeight) / (2.0 * focalLength)) * 180.0 / OFBX_PI; + return; + case Camera::ApertureMode::FOCALLENGTH: + fieldOfView = 2.0 * atan(filmHeight / (2.0 * focalLength)) * 180.0 / OFBX_PI; // Same as vertical ¯\_(ツ)_/¯ + return; + default: + fieldOfView = 60.0; + } + } +}; + +struct Root : Object +{ + Root(const Scene& _scene, const IElement& _element) + : Object(_scene, _element) + { + copyString(name, "RootNode"); + is_node = true; + } + Type getType() const override { return Type::ROOT; } +}; + + +struct Scene : IScene +{ + struct Connection + { + enum Type + { + OBJECT_OBJECT, + OBJECT_PROPERTY, + PROPERTY_OBJECT, + PROPERTY_PROPERTY, + }; + + Type type = OBJECT_OBJECT; + u64 from_object = 0; + u64 to_object = 0; + DataView from_property; + DataView to_property; + }; + + struct ObjectPair + { + const Element* element; + Object* object; + }; + + + int getAnimationStackCount() const override { return (int)m_animation_stacks.size(); } + int getGeometryCount() const override { return (int)m_geometries.size(); } + int getMeshCount() const override { return (int)m_meshes.size(); } + float getSceneFrameRate() const override { return m_scene_frame_rate; } + const GlobalSettings* getGlobalSettings() const override { return &m_settings; } + + const Object* const* getAllObjects() const override { return m_all_objects.empty() ? nullptr : &m_all_objects[0]; } + + + int getAllObjectCount() const override { return (int)m_all_objects.size(); } + + int getEmbeddedDataCount() const override { + return (int)m_videos.size(); + } + + DataView getEmbeddedData(int index) const override { + return m_videos[index].content; + } + + bool isEmbeddedBase64(int index) const override { + return m_videos[index].is_base_64; + } + + const IElementProperty* getEmbeddedBase64Data(int index) const override { + return m_videos[index].base64_property; + } + + DataView getEmbeddedFilename(int index) const override { + return m_videos[index].filename; + } + + const AnimationStack* getAnimationStack(int index) const override + { + assert(index >= 0); + assert(index < m_animation_stacks.size()); + return m_animation_stacks[index]; + } + + + const Mesh* getMesh(int index) const override + { + assert(index >= 0); + assert(index < m_meshes.size()); + return m_meshes[index]; + } + + + const Geometry* getGeometry(int index) const override + { + assert(index >= 0); + assert(index < m_geometries.size()); + return m_geometries[index]; + } + + + const TakeInfo* getTakeInfo(const char* name) const override + { + for (const TakeInfo& info : m_take_infos) + { + if (info.name == name) return &info; + } + return nullptr; + } + + const Camera* getCamera(int index) const override + { + assert(index >= 0); + assert(index < m_cameras.size()); + return m_cameras[index]; + } + + int getCameraCount() const override + { + return (int)m_cameras.size(); + } + + const Light* getLight(int index) const override + { + assert(index >= 0); + assert(index < m_lights.size()); + return m_lights[index]; + } + + int getLightCount() const override + { + return (int)m_lights.size(); + } + + + const IElement* getRootElement() const override { return m_root_element; } + const Object* getRoot() const override { return m_root; } + + + void destroy() override { delete this; } + + + ~Scene() override { + for(Object* ptr : m_all_objects) { + ptr->~Object(); + } + } + + bool finalize(); + + Element* m_root_element = nullptr; + Root* m_root = nullptr; + float m_scene_frame_rate = -1; + GlobalSettings m_settings; + + std::unordered_map m_fake_ids; + std::unordered_map m_object_map; + std::vector m_all_objects; + std::vector m_meshes; + std::vector m_geometries; + std::vector m_animation_stacks; + std::vector m_cameras; + std::vector m_lights; + std::vector m_connections; + std::vector m_data; + std::vector m_take_infos; + std::vector