diff --git a/scripts/modules/rna_info.py b/scripts/modules/rna_info.py index 574c650da41..031105bc89f 100644 --- a/scripts/modules/rna_info.py +++ b/scripts/modules/rna_info.py @@ -278,6 +278,7 @@ class InfoPropertyRNA: "is_readonly", "is_never_none", "is_path_supports_blend_relative", + "is_path_supports_templates", ) global_lookup = {} @@ -303,6 +304,7 @@ class InfoPropertyRNA: self.is_never_none = rna_prop.is_never_none self.is_argument_optional = rna_prop.is_argument_optional self.is_path_supports_blend_relative = rna_prop.is_path_supports_blend_relative + self.is_path_supports_templates = rna_prop.is_path_supports_templates self.type = rna_prop.type.lower() fixed_type = getattr(rna_prop, "fixed_type", "") @@ -483,6 +485,9 @@ class InfoPropertyRNA: if self.is_path_supports_blend_relative: type_info.append("blend relative ``//`` prefix supported") + if self.is_path_supports_templates: + type_info.append("template expressions like \"{blend_name}\" are supported") + if type_info: type_str += ", ({:s})".format(", ".join(type_info)) diff --git a/source/blender/blenkernel/BKE_blender_version.h b/source/blender/blenkernel/BKE_blender_version.h index 73a71589b00..6c69bc40286 100644 --- a/source/blender/blenkernel/BKE_blender_version.h +++ b/source/blender/blenkernel/BKE_blender_version.h @@ -27,7 +27,7 @@ /* Blender file format version. */ #define BLENDER_FILE_VERSION BLENDER_VERSION -#define BLENDER_FILE_SUBVERSION 66 +#define BLENDER_FILE_SUBVERSION 67 /* Minimum Blender version that supports reading file written with the current * version. Older Blender versions will test this and cancel loading the file, showing a warning to diff --git a/source/blender/blenkernel/BKE_image_format.hh b/source/blender/blenkernel/BKE_image_format.hh index a95ad507e21..347132cdfe2 100644 --- a/source/blender/blenkernel/BKE_image_format.hh +++ b/source/blender/blenkernel/BKE_image_format.hh @@ -10,6 +10,8 @@ #include +#include "BKE_path_templates.hh" + struct BlendDataReader; struct BlendWriter; struct ID; @@ -17,6 +19,7 @@ struct ImbFormatOptions; struct ImageFormatData; struct ImBuf; struct Scene; +struct RenderData; /* Init/Copy/Free */ @@ -36,22 +39,33 @@ void BKE_image_format_set(ImageFormatData *imf, ID *owner_id, const char imtype) /* File Paths */ -void BKE_image_path_from_imformat(char *filepath, - const char *base, - const char *relbase, - int frame, - const ImageFormatData *im_format, - bool use_ext, - bool use_frames, - const char *suffix); -void BKE_image_path_from_imtype(char *filepath, - const char *base, - const char *relbase, - int frame, - char imtype, - bool use_ext, - bool use_frames, - const char *suffix); +/** + * \param template_variables: the map of variables to use for template + * substitution. Optional: if null, template substitution will not be performed. + * + * \return If any template errors are encountered, returns those errors. On + * success, returns an empty Vector. + */ +blender::Vector BKE_image_path_from_imformat( + char *filepath, + const char *base, + const char *relbase, + const blender::bke::path_templates::VariableMap *template_variables, + int frame, + const ImageFormatData *im_format, + bool use_ext, + bool use_frames, + const char *suffix); +blender::Vector BKE_image_path_from_imtype( + char *filepath, + const char *base, + const char *relbase, + const blender::bke::path_templates::VariableMap *template_variables, + int frame, + char imtype, + bool use_ext, + bool use_frames, + const char *suffix); /** * The number of extensions an image may have (`.jpg`, `.jpeg` for example). diff --git a/source/blender/blenkernel/BKE_path_templates.hh b/source/blender/blenkernel/BKE_path_templates.hh new file mode 100644 index 00000000000..6c6166bfef6 --- /dev/null +++ b/source/blender/blenkernel/BKE_path_templates.hh @@ -0,0 +1,229 @@ +/* SPDX-FileCopyrightText: 2025 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * + * \brief Functions and classes for applying templates with variable expressions + * to filepaths. + */ +#pragma once + +#include +#include + +#include "BLI_map.hh" +#include "BLI_path_utils.hh" +#include "BLI_string.h" +#include "BLI_string_ref.hh" +#include "BLI_string_utils.hh" + +#include "BKE_report.hh" + +#include "DNA_scene_types.h" + +namespace blender::bke::path_templates { + +/** + * Variables (names and associated values) for use in template substitution. + * + * Note that this is not intended to be persistent storage, but rather is + * transient for collecting data that is relevant/available in a given + * templating context. + * + * There are currently three supported variable types: string, integer, and + * float. Names must be unique across all types: you can't have a string *and* + * integer both with the name "bob". + */ +class VariableMap { + blender::Map strings_; + blender::Map integers_; + blender::Map floats_; + + public: + /** + * Check if a variable of the given name exists. + */ + bool contains(blender::StringRef name) const; + + /** + * Remove the variable with the given name. + * + * \return True if the variable existed and was removed, false if it didn't + * exist in the first place. + */ + bool remove(blender::StringRef name); + + /** + * Add a string variable with the given name and value. + * + * If there is already a variable with that name, regardless of type, the new + * variable is *not* added (no overwriting). + * + * \return True if the variable was successfully added, false if there was + * already a variable with that name. + */ + bool add_string(blender::StringRef name, blender::StringRef value); + + /** + * Add an integer variable with the given name and value. + * + * If there is already a variable with that name, regardless of type, the new + * variable is *not* added (no overwriting). + * + * \return True if the variable was successfully added, false if there was + * already a variable with that name. + */ + bool add_integer(blender::StringRef name, int64_t value); + + /** + * Add a float variable with the given name and value. + * + * If there is already a variable with that name, regardless of type, the new + * variable is *not* added (no overwriting). + * + * \return True if the variable was successfully added, false if there was + * already a variable with that name. + */ + bool add_float(blender::StringRef name, double value); + + /** + * Fetch the value of the string variable with the given name. + * + * \return The value if a string variable with that name exists, nullopt + * otherwise. + */ + std::optional get_string(blender::StringRef name) const; + + /** + * Fetch the value of the integer variable with the given name. + * + * \return The value if a integer variable with that name exists, nullopt + * otherwise. + */ + std::optional get_integer(blender::StringRef name) const; + + /** + * Fetch the value of the float variable with the given name. + * + * \return The value if a float variable with that name exists, nullopt + * otherwise. + */ + std::optional get_float(blender::StringRef name) const; +}; + +enum class ErrorType { + UNESCAPED_CURLY_BRACE, + VARIABLE_SYNTAX, + FORMAT_SPECIFIER, + UNKNOWN_VARIABLE, +}; + +struct Error { + ErrorType type; + blender::IndexRange byte_range; +}; + +bool operator==(const Error &left, const Error &right); + +} // namespace blender::bke::path_templates + +/** + * Build a template variable map based on available information. + * + * All parameters are allowed to be null, in which case the variables derived + * from those parameters will simply not be included. + * + * This is typically used to create the variables passed to + * `BKE_path_apply_template()`. + * + * \param blend_file_path: full path to the blend file, including the file name. + * Typically you should fetch this with `ID_BLEND_PATH()`, but there are + * exceptions. The key thing is that this should be the path to the *relevant* + * blend file for the context that the variables are going to be used in. For + * example, if the context is a linked ID then this path should (very likely) be + * the path to that ID's library blend file, not the currently opened one. + * + * \param render_data: used for output resolution and fps. Note for the future: + * when we add a "current frame number" variable it should *not* come from this + * parameter, but be passed separately. This is because the callers of this + * function sometimes have the current frame defined separately from the + * available RenderData (see e.g. `do_makepicstring()`). + * + * \see BKE_path_apply_template() + * + * \see BLI_path_abs() + */ +blender::bke::path_templates::VariableMap BKE_build_template_variables( + const char *blend_file_path, const RenderData *render_data); + +/** + * Validate the template syntax in the given path. + * + * This does *not* validate whether any variables referenced in the path exist + * or not, nor whether the format specification in a variable expression is + * appropriate for its type. This only validates that the template syntax itself + * is valid. + * + * \return An empty vector if valid, or a vector of the parse errors if invalid. + */ +blender::Vector BKE_validate_template_syntax( + blender::StringRef path); + +/** + * Perform variable substitution and escaping on the given path. + * + * This mutates the path in-place. `path` must be a null-terminated string. + * + * The syntax for template expressions is `{variable_name}` or + * {variable_name:format_spec}`. The format specification syntax currently only + * applies to numerical values (integer or float), and uses hash symbols (#) to + * indicate the number of digits to print the number with. It can be in any of + * the following forms: + * + * - `####`: format as an integer with at least 4 digits, padding with zeros as + * needed. + * - `.###`: format as a float with precisely 3 fractional digits. + * - `##.###`: format as a float with at least 2 integer-part digits (padded + * with zeros as necessary) and precisely 3 fractional-part digits. + * + * This function also processes a simple escape sequence for writing literal "{" + * and "}": like Python format strings, double braces "{{" and "}}" are treated + * as escape sequences for "{" and "}", and are substituted appropriately. Note + * that this substitution only happens *outside* of the variable syntax, and + * therefore cannot e.g. be used inside variable names. + * + * If any errors are encountered, the path is left unaltered and a list of all + * errors encountered is returned. Errors include: + * + * - Variable expression syntax errors. + * - Unescaped curly braces. + * - Referenced variables that cannot be found. + * - Format specifications that don't apply to the type of variable they're + * paired with. + * + * \param path_max_length The maximum length that template expansion is allowed + * to make the template-expanded path (in bytes), including the null terminator. + * In general, this should be the size of the underlying allocation of `path`. + * + * \return On success, an empty vector. If there are errors, a vector of all + * errors encountered. + */ +blender::Vector BKE_path_apply_template( + char *path, + int path_max_length, + const blender::bke::path_templates::VariableMap &template_variables); +/** + * Produces a human-readable error message for the given template error. + */ +std::string BKE_path_template_error_to_string(const blender::bke::path_templates::Error &error, + blender::StringRef path); + +/** + * Logs a report for the given template errors, with human-readable error + * messages. + */ +void BKE_report_path_template_errors(ReportList *reports, + eReportType report_type, + blender::StringRef path, + blender::Span errors); diff --git a/source/blender/blenkernel/CMakeLists.txt b/source/blender/blenkernel/CMakeLists.txt index abdc7050394..67e5651ab11 100644 --- a/source/blender/blenkernel/CMakeLists.txt +++ b/source/blender/blenkernel/CMakeLists.txt @@ -252,6 +252,7 @@ set(SRC intern/particle_child.cc intern/particle_distribute.cc intern/particle_system.cc + intern/path_templates.cc intern/pbvh.cc intern/pbvh_bmesh.cc intern/pbvh_pixels.cc @@ -484,6 +485,7 @@ set(SRC BKE_paint_bvh.hh BKE_paint_bvh_pixels.hh BKE_particle.h + BKE_path_templates.hh BKE_pointcache.h BKE_pointcloud.hh BKE_pose_backup.h @@ -835,6 +837,7 @@ if(WITH_GTESTS) intern/lib_remap_test.cc intern/main_test.cc intern/nla_test.cc + intern/path_templates_test.cc intern/subdiv_ccg_test.cc intern/tracking_test.cc intern/volume_test.cc diff --git a/source/blender/blenkernel/intern/image_format.cc b/source/blender/blenkernel/intern/image_format.cc index b4929ed25a3..268e6358364 100644 --- a/source/blender/blenkernel/intern/image_format.cc +++ b/source/blender/blenkernel/intern/image_format.cc @@ -22,6 +22,9 @@ #include "BKE_colortools.hh" #include "BKE_image_format.hh" +#include "BKE_path_templates.hh" + +namespace path_templates = blender::bke::path_templates; /* Init/Copy/Free */ @@ -597,20 +600,31 @@ int BKE_image_path_ext_from_imtype_ensure(char *filepath, return do_ensure_image_extension(filepath, filepath_maxncpy, imtype, nullptr); } -static void do_makepicstring(char filepath[FILE_MAX], - const char *base, - const char *relbase, - int frame, - const char imtype, - const ImageFormatData *im_format, - const bool use_ext, - const bool use_frames, - const char *suffix) +static blender::Vector do_makepicstring( + char filepath[FILE_MAX], + const char *base, + const char *relbase, + const path_templates::VariableMap *template_variables, + int frame, + const char imtype, + const ImageFormatData *im_format, + const bool use_ext, + const bool use_frames, + const char *suffix) { if (filepath == nullptr) { - return; + return {}; } BLI_strncpy(filepath, base, FILE_MAX - 10); /* weak assumption */ + + if (template_variables) { + const blender::Vector variable_errors = BKE_path_apply_template( + filepath, FILE_MAX, *template_variables); + if (!variable_errors.is_empty()) { + return variable_errors; + } + } + BLI_path_abs(filepath, relbase); if (use_frames) { @@ -624,31 +638,54 @@ static void do_makepicstring(char filepath[FILE_MAX], if (use_ext) { do_ensure_image_extension(filepath, FILE_MAX, imtype, im_format); } + + return {}; } -void BKE_image_path_from_imformat(char *filepath, - const char *base, - const char *relbase, - int frame, - const ImageFormatData *im_format, - const bool use_ext, - const bool use_frames, - const char *suffix) +blender::Vector BKE_image_path_from_imformat( + char *filepath, + const char *base, + const char *relbase, + const path_templates::VariableMap *template_variables, + int frame, + const ImageFormatData *im_format, + const bool use_ext, + const bool use_frames, + const char *suffix) { - do_makepicstring( - filepath, base, relbase, frame, im_format->imtype, im_format, use_ext, use_frames, suffix); + return do_makepicstring(filepath, + base, + relbase, + template_variables, + frame, + im_format->imtype, + im_format, + use_ext, + use_frames, + suffix); } -void BKE_image_path_from_imtype(char *filepath, - const char *base, - const char *relbase, - int frame, - const char imtype, - const bool use_ext, - const bool use_frames, - const char *suffix) +blender::Vector BKE_image_path_from_imtype( + char *filepath, + const char *base, + const char *relbase, + const path_templates::VariableMap *template_variables, + int frame, + const char imtype, + const bool use_ext, + const bool use_frames, + const char *suffix) { - do_makepicstring(filepath, base, relbase, frame, imtype, nullptr, use_ext, use_frames, suffix); + return do_makepicstring(filepath, + base, + relbase, + template_variables, + frame, + imtype, + nullptr, + use_ext, + use_frames, + suffix); } /* ImBuf Conversion */ diff --git a/source/blender/blenkernel/intern/ocean.cc b/source/blender/blenkernel/intern/ocean.cc index 8bc46219661..5135fbb705e 100644 --- a/source/blender/blenkernel/intern/ocean.cc +++ b/source/blender/blenkernel/intern/ocean.cc @@ -1141,8 +1141,11 @@ static void cache_filepath( BLI_path_join(cachepath, sizeof(cachepath), dirname, filename); - BKE_image_path_from_imtype( - filepath, cachepath, relbase, frame, R_IMF_IMTYPE_OPENEXR, true, true, ""); + const blender::Vector errors = BKE_image_path_from_imtype( + filepath, cachepath, relbase, nullptr, frame, R_IMF_IMTYPE_OPENEXR, true, true, ""); + BLI_assert_msg(errors.is_empty(), + "Path parsing errors should only occur when a variable map is provided."); + UNUSED_VARS_NDEBUG(errors); } /* silly functions but useful to inline when the args do a lot of indirections */ diff --git a/source/blender/blenkernel/intern/path_templates.cc b/source/blender/blenkernel/intern/path_templates.cc new file mode 100644 index 00000000000..a569a397f66 --- /dev/null +++ b/source/blender/blenkernel/intern/path_templates.cc @@ -0,0 +1,766 @@ +/* SPDX-FileCopyrightText: 2025 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include "BLT_translation.hh" + +#include "BKE_path_templates.hh" +#include "BKE_scene.hh" + +namespace blender::bke::path_templates { + +bool VariableMap::contains(blender::StringRef name) const +{ + if (this->strings_.contains(name)) { + return true; + } + if (this->integers_.contains(name)) { + return true; + } + if (this->floats_.contains(name)) { + return true; + } + return false; +} + +bool VariableMap::remove(blender::StringRef name) +{ + if (this->strings_.remove(name)) { + return true; + } + if (this->integers_.remove(name)) { + return true; + } + if (this->floats_.remove(name)) { + return true; + } + return false; +} + +bool VariableMap::add_string(blender::StringRef name, blender::StringRef value) +{ + if (this->contains(name)) { + return false; + } + this->strings_.add_new(name, value); + return true; +} + +bool VariableMap::add_integer(blender::StringRef name, const int64_t value) +{ + if (this->contains(name)) { + return false; + } + this->integers_.add_new(name, value); + return true; +} + +bool VariableMap::add_float(blender::StringRef name, const double value) +{ + if (this->contains(name)) { + return false; + } + this->floats_.add_new(name, value); + return true; +} + +std::optional VariableMap::get_string(blender::StringRef name) const +{ + const std::string *value = this->strings_.lookup_ptr(name); + if (value == nullptr) { + return std::nullopt; + } + return blender::StringRefNull(*value); +} + +std::optional VariableMap::get_integer(blender::StringRef name) const +{ + const int64_t *value = this->integers_.lookup_ptr(name); + if (value == nullptr) { + return std::nullopt; + } + return *value; +} + +std::optional VariableMap::get_float(blender::StringRef name) const +{ + const double *value = this->floats_.lookup_ptr(name); + if (value == nullptr) { + return std::nullopt; + } + return *value; +} + +bool operator==(const Error &left, const Error &right) +{ + return left.type == right.type && left.byte_range == right.byte_range; +} + +} // namespace blender::bke::path_templates + +using namespace blender::bke::path_templates; + +VariableMap BKE_build_template_variables(const char *blend_file_path, + const RenderData *render_data) +{ + VariableMap variables; + + /* Blend file name. */ + if (blend_file_path) { + const char *file_name = BLI_path_basename(blend_file_path); + const char *file_name_end = BLI_path_extension_or_end(file_name); + if (file_name[0] == '\0') { + /* If the file has never been saved (indicated by an empty file name), + * default to "Unsaved". */ + variables.add_string("blend_name", blender::StringRef(DATA_("Unsaved"))); + } + else if (file_name_end == file_name) { + /* When the filename has no extension, but starts with a period. */ + variables.add_string("blend_name", blender::StringRef(file_name)); + } + else { + /* Normal case. */ + variables.add_string("blend_name", blender::StringRef(file_name, file_name_end)); + } + } + + /* Render resolution and fps. */ + if (render_data) { + int res_x, res_y; + BKE_render_resolution(render_data, false, &res_x, &res_y); + variables.add_integer("resolution_x", res_x); + variables.add_integer("resolution_y", res_y); + + /* FPS eval code copied from `BKE_cachefile_filepath_get()`. + * + * TODO: should probably use one function for this everywhere to ensure that + * fps is computed consistently, but at the time of writing no such function + * seems to exist. Every place in the code base just has its own bespoke + * code, using different precision, etc. */ + const double fps = double(render_data->frs_sec) / double(render_data->frs_sec_base); + variables.add_float("fps", fps); + } + + return variables; +} + +/* -------------------------------------------------------------------- */ + +#define FORMAT_BUFFER_SIZE 128 + +namespace { + +enum class FormatSpecifierType { + /* No format specifier given. Use default formatting. */ + NONE = 0, + + /* The format specifier was a string of just "#" characters. E.g. "####". */ + INTEGER, + + /* The format specifier was a string of "#" characters with a single ".". E.g. + * "###.##". */ + FLOAT, + + /* The format specifier was invalid due to incorrect syntax. */ + SYNTAX_ERROR, +}; + +/** + * Specifies how a variable should be formatted into a string, or indicates a + * parse error. + */ +struct FormatSpecifier { + FormatSpecifierType type = FormatSpecifierType::NONE; + + /* For INTEGER and FLOAT formatting types, the number of digits indicated on + * either side of the decimal point. */ + std::optional integer_digit_count; + std::optional fractional_digit_count; +}; + +enum class TokenType { + /* Either "{variable_name}" or "{variable_name:format_spec}". */ + VARIABLE_EXPRESSION, + + /* "{{", which is an escaped "{". */ + LEFT_CURLY_BRACE, + + /* "}}", which is an escaped "}". */ + RIGHT_CURLY_BRACE, + + /* Encountered a syntax error while trying to parse a variable expression. */ + VARIABLE_SYNTAX_ERROR, + + /* Encountered an unescaped curly brace in an invalid position. */ + UNESCAPED_CURLY_BRACE_ERROR, +}; + +/** + * A token that was parsed and should be substituted in the string, or an error. + */ +struct Token { + TokenType type = TokenType::VARIABLE_EXPRESSION; + + /* Byte index range (exclusive on the right) of the token or syntax error in + * the path string. */ + blender::IndexRange byte_range; + + /* Reference to the the variable name as written in the template string. Note + * that this points into the template string, and does not own the value. + * + * Only relevant when `type == VARIABLE_EXPRESSION`. */ + blender::StringRef variable_name; + + /* Indicates how the variable's value should be formatted into a string. This + * is derived from the format specification (e.g. the "###" in "{blah:###}"). + * + * Only relevant when `type == VARIABLE_EXPRESSION`. */ + FormatSpecifier format; +}; + +} // namespace + +/** + * Format an integer into a string, according to `format`. + * + * Note: if `format` is not valid for integers, the resulting string will be + * empty. + * + * \return length of the produced string. Zero indicates an error. + */ +static int format_int_to_string(const FormatSpecifier &format, + const int64_t integer_value, + char r_output_string[FORMAT_BUFFER_SIZE]) +{ + BLI_assert(format.type != FormatSpecifierType::SYNTAX_ERROR); + + r_output_string[0] = '\0'; + int output_length = 0; + + switch (format.type) { + case FormatSpecifierType::NONE: { + output_length = sprintf(r_output_string, "%ld", integer_value); + break; + } + + case FormatSpecifierType::INTEGER: { + BLI_assert(format.integer_digit_count.has_value()); + BLI_assert(*format.integer_digit_count > 0); + output_length = sprintf( + r_output_string, "%0*ld", *format.integer_digit_count, integer_value); + break; + } + + case FormatSpecifierType::FLOAT: { + /* Formatting an integer as a float: we do *not* defer to the float + * formatter for this because we could lose precision with very large + * numbers. Instead we simply print the integer, and then append ".000..." + * to it. */ + BLI_assert(format.fractional_digit_count.has_value()); + BLI_assert(*format.fractional_digit_count > 0); + + if (format.integer_digit_count.has_value()) { + BLI_assert(*format.integer_digit_count > 0); + output_length = sprintf( + r_output_string, "%0*ld", *format.integer_digit_count, integer_value); + } + else { + output_length = sprintf(r_output_string, "%ld", integer_value); + } + + r_output_string[output_length] = '.'; + output_length++; + + for (int i = 0; i < *format.fractional_digit_count; i++) { + r_output_string[output_length] = '0'; + output_length++; + } + + r_output_string[output_length] = '\0'; + + break; + } + + case FormatSpecifierType::SYNTAX_ERROR: { + BLI_assert_msg( + false, + "Format specifiers with invalid syntax should have been rejected before getting here."); + break; + } + } + + return output_length; +} + +/** + * Format a floating point number into a string, according to `format`. + * + * Note: if `format` is not valid for floating point numbers, the resulting + * string will be empty. + * + * \return length of the produced string. Zero indicates an error + */ +static int format_float_to_string(const FormatSpecifier &format, + const double float_value, + char r_output_string[FORMAT_BUFFER_SIZE]) +{ + BLI_assert(format.type != FormatSpecifierType::SYNTAX_ERROR); + + r_output_string[0] = '\0'; + int output_length = 0; + + switch (format.type) { + case FormatSpecifierType::NONE: { + /* When no format specification is given, we attempt to approximate + * Python's behavior in the same situation. We can't exactly match via + * `sprintf()`, but we can get pretty close. The only major thing we can't + * replicate via `sprintf()` is that in Python whole numbers are printed + * with a trailing ".0". So we handle that bit manually. */ + output_length = sprintf(r_output_string, "%.16g", float_value); + + /* If the string consists only of digits and a possible negative sign, then + * we append a ".0" to match Python. */ + if (blender::StringRef(r_output_string).find_first_not_of("-0123456789") == + std::string::npos) + { + r_output_string[output_length] = '.'; + r_output_string[output_length + 1] = '0'; + r_output_string[output_length + 2] = '\0'; + output_length += 2; + } + break; + } + + case FormatSpecifierType::INTEGER: { + /* Defer to the integer formatter with a rounded value. */ + return format_int_to_string(format, std::round(float_value), r_output_string); + } + + case FormatSpecifierType::FLOAT: { + BLI_assert(format.fractional_digit_count.has_value()); + BLI_assert(*format.fractional_digit_count > 0); + + if (format.integer_digit_count.has_value()) { + /* Both integer and fractional component lengths are specified. */ + BLI_assert(*format.integer_digit_count > 0); + output_length = sprintf(r_output_string, + "%0*.*f", + *format.integer_digit_count + *format.fractional_digit_count + 1, + *format.fractional_digit_count, + float_value); + } + else { + /* Only fractional component length is specified. */ + output_length = sprintf( + r_output_string, "%.*f", *format.fractional_digit_count, float_value); + } + + break; + } + + case FormatSpecifierType::SYNTAX_ERROR: { + BLI_assert_msg( + false, + "Format specifiers with invalid syntax should have been rejected before getting here."); + break; + } + } + + return output_length; +} + +/** + * Parse the "format specifier" part of a variable expression. + * + * The format specifier is e.g. the "##.###" in "{name:##.###}". The specifier + * string should be passed alone (just the "##.###"), without the rest of the + * variable expression. + */ +static FormatSpecifier parse_format_specifier(blender::StringRef format_specifier) +{ + FormatSpecifier format = {}; + + /* A ":" was used, but no format specifier was given, which is invalid. */ + if (format_specifier.is_empty()) { + format.type = FormatSpecifierType::SYNTAX_ERROR; + return format; + } + + /* If it's all digit specifiers, then format as an integer. */ + if (format_specifier.find_first_not_of("#") == std::string::npos) { + format.integer_digit_count = format_specifier.size(); + + format.type = FormatSpecifierType::INTEGER; + return format; + } + + /* If it's digit specifiers and a dot, format as a float. */ + const int64_t dot_index = format_specifier.find_first_of('.'); + const int64_t dot_index_last = format_specifier.find_last_of('.'); + const bool found_dot = dot_index != std::string::npos; + const bool only_one_dot = dot_index == dot_index_last; + if (format_specifier.find_first_not_of(".#") == std::string::npos && found_dot && only_one_dot) { + blender::StringRef left = format_specifier.substr(0, dot_index); + blender::StringRef right = format_specifier.substr(dot_index + 1); + + /* We currently require that the fractional digits are specified, so bail if + * they aren't. */ + if (right.is_empty()) { + format.type = FormatSpecifierType::SYNTAX_ERROR; + return format; + } + + if (!left.is_empty()) { + format.integer_digit_count = left.size(); + } + + format.fractional_digit_count = right.size(); + + format.type = FormatSpecifierType::FLOAT; + return format; + } + + format.type = FormatSpecifierType::SYNTAX_ERROR; + return format; +} + +/** + * Find and parse the next valid token in `path` starting from index + * `from_char`. + * + * \param path The path string to parse. + * + * \param from_char The char index to start from. + * + * \return The parsed token information, or nullopt if no token is found in + * `path`. + */ +static std::optional next_token(blender::StringRef path, const int from_char) +{ + Token token; + + /* We use the magic number -1 here to indicate that a component hasn't been + * found yet. When a component is found, the respective token here is set + * to the byte offset it was found at. */ + int start = -1; /* "{" */ + int format_specifier_split = -1; /* ":" */ + int end = -1; /* "}" */ + + for (int byte_index = from_char; byte_index < path.size(); byte_index++) { + /* Check for escaped "{". */ + if (start == -1 && (byte_index + 1) < path.size() && path[byte_index] == '{' && + path[byte_index + 1] == '{') + { + Token token; + token.type = TokenType::LEFT_CURLY_BRACE; + token.byte_range = blender::IndexRange::from_begin_end(byte_index, byte_index + 2); + return token; + } + + /* Check for escaped "}". + * + * Note that we only do this check when not already inside a variable + * expression, since it could be a valid closing "}" followed by additional + * escaped closing braces. */ + if (start == -1 && (byte_index + 1) < path.size() && path[byte_index] == '}' && + path[byte_index + 1] == '}') + { + token.type = TokenType::RIGHT_CURLY_BRACE; + token.byte_range = blender::IndexRange::from_begin_end(byte_index, byte_index + 2); + return token; + } + + /* Check for unescaped "}", which outside of a variable expression is + * illegal. */ + if (start == -1 && path[byte_index] == '}') { + token.type = TokenType::UNESCAPED_CURLY_BRACE_ERROR; + token.byte_range = blender::IndexRange::from_begin_end(byte_index, byte_index + 1); + return token; + } + + /* Check if we've found a starting "{". */ + if (path[byte_index] == '{') { + if (start != -1) { + /* Already inside a variable expression. */ + token.type = TokenType::VARIABLE_SYNTAX_ERROR; + token.byte_range = blender::IndexRange::from_begin_end(start, byte_index); + return token; + } + start = byte_index; + format_specifier_split = -1; + continue; + } + + /* If we haven't found a start, we shouldn't try to parse the other bits + * yet. */ + if (start == -1) { + continue; + } + + /* Check if we've found a format splitter. */ + if (path[byte_index] == ':') { + if (format_specifier_split == -1) { + /* Only set if it's the first ":" we've encountered in the variable + * expression. Subsequent ones will be handled in the format specifier + * parsing. */ + format_specifier_split = byte_index; + } + continue; + } + + /* Check if we've found the closing "}". */ + if (path[byte_index] == '}') { + end = byte_index + 1; /* Exclusive end. */ + break; + } + } + + /* No variable expression found. */ + if (start == -1) { + return std::nullopt; + } + + /* Unclosed variable expression. Syntax error. */ + if (end == -1) { + token.type = TokenType::VARIABLE_SYNTAX_ERROR; + token.byte_range = blender::IndexRange::from_begin_end(start, path.size()); + return token; + } + + /* Parse the variable expression we found. */ + token.byte_range = blender::IndexRange::from_begin_end(start, end); + if (format_specifier_split == -1) { + /* No format specifier. */ + token.variable_name = path.substr(start + 1, (end - 1) - (start + 1)); + } + else { + /* Found format specifier. */ + token.variable_name = path.substr(start + 1, format_specifier_split - (start + 1)); + token.format = parse_format_specifier( + path.substr(format_specifier_split + 1, (end - 1) - (format_specifier_split + 1))); + if (token.format.type == FormatSpecifierType::SYNTAX_ERROR) { + token.type = TokenType::VARIABLE_SYNTAX_ERROR; + return token; + } + } + + return token; +} + +/* Parse the given template and return the list of tokens found, in the same + * order as they appear in the template. */ +static blender::Vector parse_template(blender::StringRef path) +{ + blender::Vector tokens; + + for (int bytes_read = 0; bytes_read < path.size();) { + const std::optional token = next_token(path, bytes_read); + + if (!token.has_value()) { + break; + } + + bytes_read = token->byte_range.one_after_last(); + tokens.append(*token); + } + + return tokens; +} + +/* Convert a token to its corresponding syntax error. If the token doesn't have + * an error, returns nullopt. */ +static std::optional token_to_syntax_error(const Token &token) +{ + switch (token.type) { + case TokenType::VARIABLE_SYNTAX_ERROR: { + if (token.format.type == FormatSpecifierType::SYNTAX_ERROR) { + return {{ErrorType::FORMAT_SPECIFIER, token.byte_range}}; + } + else { + return {{ErrorType::VARIABLE_SYNTAX, token.byte_range}}; + } + } + + case TokenType::UNESCAPED_CURLY_BRACE_ERROR: { + return {{ErrorType::UNESCAPED_CURLY_BRACE, token.byte_range}}; + } + + /* Non-errors. */ + case TokenType::VARIABLE_EXPRESSION: + case TokenType::LEFT_CURLY_BRACE: + case TokenType::RIGHT_CURLY_BRACE: + return std::nullopt; + } + + BLI_assert_msg(false, "Unhandled token type."); + return std::nullopt; +} + +blender::Vector BKE_validate_template_syntax(blender::StringRef path) +{ + const blender::Vector tokens = parse_template(path); + + blender::Vector errors; + for (const Token &token : tokens) { + if (std::optional error = token_to_syntax_error(token)) { + errors.append(*error); + } + } + + return errors; +} + +blender::Vector BKE_path_apply_template(char *path, + int path_max_length, + const VariableMap &template_variables) +{ + const blender::Vector tokens = parse_template(path); + + if (tokens.is_empty()) { + /* No tokens found, so nothing to do. */ + return {}; + } + + /* Accumulates errors as we process the tokens. */ + blender::Vector errors; + + /* We work on a copy of the path, for two reasons: + * + * 1. So that if there are errors we can leave the original unmodified. + * 2. So that the contents of the StringRefs in the Token structs don't change + * out from under us while we're generating the modified path.*/ + blender::Vector path_buffer(path_max_length); + char *path_modified = path_buffer.data(); + strcpy(path_modified, path); + + /* Tracks the change in string length due to the modifications as we go. We + * need this to properly map the token byte ranges to the being-modified + * string. */ + int length_diff = 0; + + for (const Token &token : tokens) { + /* Syntax errors. */ + if (std::optional error = token_to_syntax_error(token)) { + errors.append(*error); + continue; + } + + char replacement_string[FORMAT_BUFFER_SIZE]; + + switch (token.type) { + /* Syntax errors should have been handled above. */ + case TokenType::VARIABLE_SYNTAX_ERROR: + case TokenType::UNESCAPED_CURLY_BRACE_ERROR: { + BLI_assert_msg(false, "Unhandled syntax error."); + continue; + } + + /* Curly brace escapes. */ + case TokenType::LEFT_CURLY_BRACE: { + strcpy(replacement_string, "{"); + break; + } + case TokenType::RIGHT_CURLY_BRACE: { + strcpy(replacement_string, "}"); + break; + } + + /* Expand variable expression into the variable's value. */ + case TokenType::VARIABLE_EXPRESSION: { + if (std::optional string_value = template_variables.get_string( + token.variable_name)) + { + /* String variable found, but we only process it if there's no format + * specifier: string variables do not support format specifiers. */ + if (token.format.type != FormatSpecifierType::NONE) { + /* String variables don't take format specifiers: error. */ + errors.append({ErrorType::FORMAT_SPECIFIER, token.byte_range}); + continue; + } + strcpy(replacement_string, string_value->c_str()); + break; + } + + if (std::optional integer_value = template_variables.get_integer( + token.variable_name)) + { + /* Integer variable found. */ + format_int_to_string(token.format, *integer_value, replacement_string); + break; + } + + if (std::optional float_value = template_variables.get_float(token.variable_name)) + { + /* Float variable found. */ + format_float_to_string(token.format, *float_value, replacement_string); + break; + } + + /* No matching variable found: error. */ + errors.append({ErrorType::UNKNOWN_VARIABLE, token.byte_range}); + continue; + } + } + + /* We're off the end of the available space. */ + if (token.byte_range.start() + length_diff >= path_max_length) { + break; + } + + BLI_string_replace_range(path_modified, + path_max_length, + token.byte_range.start() + length_diff, + token.byte_range.one_after_last() + length_diff, + replacement_string); + + length_diff -= token.byte_range.size(); + length_diff += strlen(replacement_string); + } + + if (errors.is_empty()) { + /* No errors, so copy the modified path back to the original. */ + strcpy(path, path_modified); + } + return errors; +} + +std::string BKE_path_template_error_to_string(const Error &error, blender::StringRef path) +{ + blender::StringRef subpath = path.substr(error.byte_range.start(), error.byte_range.size()); + + switch (error.type) { + case ErrorType::UNESCAPED_CURLY_BRACE: { + return std::string("Unescaped curly brace '") + subpath + "'."; + } + + case ErrorType::VARIABLE_SYNTAX: { + return std::string("Invalid or incomplete template expression '") + subpath + "'."; + } + + case ErrorType::FORMAT_SPECIFIER: { + return std::string("Invalid format specifier in template expression '") + subpath + "'."; + } + + case ErrorType::UNKNOWN_VARIABLE: { + return std::string("Unknown variable referenced in template expression '") + subpath + "'."; + } + } + + BLI_assert_msg(false, "Unhandled error type."); + return "Unknown error."; +} + +void BKE_report_path_template_errors(ReportList *reports, + const eReportType report_type, + blender::StringRef path, + blender::Span errors) +{ + BLI_assert(!errors.is_empty()); + + std::string error_message = "Parse errors in path '" + path + "':"; + for (const Error &error : errors) { + error_message += "\n- " + BKE_path_template_error_to_string(error, path); + } + + BKE_report(reports, report_type, error_message.c_str()); +} diff --git a/source/blender/blenkernel/intern/path_templates_test.cc b/source/blender/blenkernel/intern/path_templates_test.cc new file mode 100644 index 00000000000..2b364e59a74 --- /dev/null +++ b/source/blender/blenkernel/intern/path_templates_test.cc @@ -0,0 +1,342 @@ +/* SPDX-FileCopyrightText: 2025 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#include "BKE_path_templates.hh" + +#include "testing/testing.h" + +namespace blender::bke::tests { + +using namespace blender::bke::path_templates; + +[[maybe_unused]] static void debug_print_error(const Error &error) +{ + const char *type; + switch (error.type) { + case ErrorType::UNESCAPED_CURLY_BRACE: + type = "UNESCAPED_CURLY_BRACE"; + break; + case ErrorType::VARIABLE_SYNTAX: + type = "VARIABLE_SYNTAX"; + break; + case ErrorType::FORMAT_SPECIFIER: + type = "FORMAT_SPECIFIER"; + break; + case ErrorType::UNKNOWN_VARIABLE: + type = "UNKNOWN_VARIABLE"; + break; + } + printf("(%s, (%ld, %ld))", type, error.byte_range.start(), error.byte_range.size()); +} + +[[maybe_unused]] static void debug_print_errors(Span errors) +{ + printf("["); + for (const Error &error : errors) { + debug_print_error(error); + printf(", "); + } + printf("]\n"); +} + +TEST(path_templates, VariableMap) +{ + VariableMap map; + + /* With in empty variable map, these should all return false / fail. */ + EXPECT_FALSE(map.contains("hello")); + EXPECT_FALSE(map.remove("hello")); + EXPECT_EQ(std::nullopt, map.get_string("hello")); + EXPECT_EQ(std::nullopt, map.get_integer("hello")); + EXPECT_EQ(std::nullopt, map.get_float("hello")); + + /* Populate the map. */ + EXPECT_TRUE(map.add_string("hello", "What a wonderful world.")); + EXPECT_TRUE(map.add_integer("bye", 42)); + EXPECT_TRUE(map.add_float("what", 3.14159)); + + /* Attempting to add variables with those names again should fail, since they + * already exist now. */ + EXPECT_FALSE(map.add_string("hello", "Sup.")); + EXPECT_FALSE(map.add_string("bye", "Sup.")); + EXPECT_FALSE(map.add_string("what", "Sup.")); + EXPECT_FALSE(map.add_integer("hello", 2)); + EXPECT_FALSE(map.add_integer("bye", 2)); + EXPECT_FALSE(map.add_integer("what", 2)); + EXPECT_FALSE(map.add_float("hello", 2.71828)); + EXPECT_FALSE(map.add_float("bye", 2.71828)); + EXPECT_FALSE(map.add_float("what", 2.71828)); + + /* Confirm that the right variables exist. */ + EXPECT_TRUE(map.contains("hello")); + EXPECT_TRUE(map.contains("bye")); + EXPECT_TRUE(map.contains("what")); + EXPECT_FALSE(map.contains("not here")); + + /* Fetch the variables we added. */ + EXPECT_EQ("What a wonderful world.", map.get_string("hello")); + EXPECT_EQ(42, map.get_integer("bye")); + EXPECT_EQ(3.14159, map.get_float("what")); + + /* The same variables shouldn't exist for the other types, despite our attempt + * to add them earlier. */ + EXPECT_EQ(std::nullopt, map.get_integer("hello")); + EXPECT_EQ(std::nullopt, map.get_float("hello")); + EXPECT_EQ(std::nullopt, map.get_string("bye")); + EXPECT_EQ(std::nullopt, map.get_float("bye")); + EXPECT_EQ(std::nullopt, map.get_string("what")); + EXPECT_EQ(std::nullopt, map.get_integer("what")); + + /* Remove the variables. */ + EXPECT_TRUE(map.remove("hello")); + EXPECT_TRUE(map.remove("bye")); + EXPECT_TRUE(map.remove("what")); + + /* The variables shouldn't exist anymore. */ + EXPECT_FALSE(map.contains("hello")); + EXPECT_FALSE(map.contains("bye")); + EXPECT_FALSE(map.contains("what")); + EXPECT_EQ(std::nullopt, map.get_string("hello")); + EXPECT_EQ(std::nullopt, map.get_integer("bye")); + EXPECT_EQ(std::nullopt, map.get_float("what")); + EXPECT_FALSE(map.remove("hello")); + EXPECT_FALSE(map.remove("bye")); + EXPECT_FALSE(map.remove("what")); +} + +TEST(path_templates, path_apply_variables) +{ + VariableMap variables; + { + variables.add_string("hi", "hello"); + variables.add_string("bye", "goodbye"); + variables.add_string("long", "This string is exactly 32 bytes."); + variables.add_integer("the_answer", 42); + variables.add_integer("prime", 7); + variables.add_integer("i_negative", -7); + variables.add_float("pi", 3.14159265358979323846); + variables.add_float("e", 2.71828182845904523536); + variables.add_float("ntsc", 30.0 / 1.001); + variables.add_float("two", 2.0); + variables.add_float("f_negative", -3.14159265358979323846); + variables.add_float("huge", 200000000000000000000000000000000.0); + variables.add_float("tiny", 0.000000000000000000000000000000002); + } + + /* Simple case, testing all variables. */ + { + char path[FILE_MAX] = + "{hi}_{bye}_{the_answer}_{prime}_{i_negative}_{pi}_{e}_{ntsc}_{two}_{f_negative}_{huge}_{" + "tiny}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), + "hello_goodbye_42_7_-7_3.141592653589793_2.718281828459045_29.97002997002997_2.0_-3." + "141592653589793_2e+32_2e-33"); + } + + /* Integer formatting. */ + { + char path[FILE_MAX] = "{the_answer:#}_{the_answer:##}_{the_answer:####}_{i_negative:####}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), "42_42_0042_-007"); + } + + /* Integer formatting as float. */ + { + char path[FILE_MAX] = + "{the_answer:.###}_{the_answer:#.##}_{the_answer:###.##}_{i_negative:###.####}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), "42.000_42.00_042.00_-07.0000"); + } + + /* Float formatting: specify fractional digits only. */ + { + char path[FILE_MAX] = + "{pi:.####}_{e:.###}_{ntsc:.########}_{two:.##}_{f_negative:.##}_{huge:.##}_{tiny:.##}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), + "3.1416_2.718_29.97002997_2.00_-3.14_200000000000000010732324408786944.00_0.00"); + } + + /* Float formatting: specify both integer and fractional digits. */ + { + char path[FILE_MAX] = + "{pi:##.####}_{e:####.###}_{ntsc:#.########}_{two:###.##}_{f_negative:###.##}_{huge:###.##" + "}_{tiny:###.##}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ( + blender::StringRef(path), + "03.1416_0002.718_29.97002997_002.00_-03.14_200000000000000010732324408786944.00_000.00"); + } + + /* Float formatting: format as integer. */ + { + char path[FILE_MAX] = "{pi:##}_{e:####}_{ntsc:#}_{two:###}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), "03_0003_30_002"); + } + + /* Escaping. "{{" and "}}" are the escape codes for literal "{" and "}". */ + { + char path[FILE_MAX] = "{hi}_{{hi}}_{{{bye}}}_{bye}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), "hello_{hi}_{goodbye}_goodbye"); + } + + /* Error: string variables do not support format specifiers. */ + { + char path[FILE_MAX] = "{hi:##}_{bye:#}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::FORMAT_SPECIFIER, IndexRange(0, 7)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(8, 7)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), "{hi:##}_{bye:#}"); + } + + /* Error: float formatting: specifying integer digits only (but still wanting + * it printed as a float) is currently not supported. */ + { + char path[FILE_MAX] = + "{pi:##.}_{e:####.}_{ntsc:#.}_{two:###.}_{f_negative:###.}_{huge:###.}_{tiny:###.}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::FORMAT_SPECIFIER, IndexRange(0, 8)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(9, 9)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(19, 9)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(29, 10)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(40, 17)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(58, 11)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(70, 11)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), + "{pi:##.}_{e:####.}_{ntsc:#.}_{two:###.}_{f_negative:###.}_{huge:###.}_{tiny:###.}"); + } + + /* Error: missing variable. */ + { + char path[FILE_MAX] = "{hi}_{missing}_{bye}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::UNKNOWN_VARIABLE, IndexRange(5, 9)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), "{hi}_{missing}_{bye}"); + } + + /* Error: incomplete variable expression. */ + { + char path[FILE_MAX] = "foo{hi"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::VARIABLE_SYNTAX, IndexRange(3, 3)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), "foo{hi"); + } + + /* Error: invalid format specifiers. */ + { + char path[FILE_MAX] = "{prime:}_{prime:.}_{prime:#.#.#}_{prime:sup}_{prime::sup}_{prime}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::FORMAT_SPECIFIER, IndexRange(0, 8)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(9, 9)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(19, 13)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(33, 11)}, + {ErrorType::FORMAT_SPECIFIER, IndexRange(45, 12)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), + "{prime:}_{prime:.}_{prime:#.#.#}_{prime:sup}_{prime::sup}_{prime}"); + } + + /* Error: unclosed variable. */ + { + char path[FILE_MAX] = "{hi_{hi}_{bye}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::VARIABLE_SYNTAX, IndexRange(0, 4)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), "{hi_{hi}_{bye}"); + } + + /* Error: escaped braces inside variable. */ + { + char path[FILE_MAX] = "{hi_{{hi}}_{bye}"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + const Vector expected_errors = { + {ErrorType::VARIABLE_SYNTAX, IndexRange(0, 4)}, + }; + + EXPECT_EQ(errors, expected_errors); + EXPECT_EQ(blender::StringRef(path), "{hi_{{hi}}_{bye}"); + } + + /* Test what happens when the path would expand to a string that's longer than + * `FILE_MAX`. + * + * We don't care so much about any kind of "correctness" here, we just want to + * ensure that it still results in a valid null-terminated string that fits in + * `FILE_MAX` bytes. + * + * NOTE: this test will have to be updated if `FILE_MAX` is ever changed. */ + { + char path[FILE_MAX] = + "___{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}" + "{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}" + "{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}" + "{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}" + "{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}" + "{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{" + "long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}"; + const char result[FILE_MAX] = + "___This string is exactly 32 bytes.This string is exactly 32 bytes.This string is " + "exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This " + "string is exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 " + "bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This string is " + "exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This " + "string is exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 " + "bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This string is " + "exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This " + "string is exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 " + "bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This string is " + "exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 bytes.This " + "string is exactly 32 bytes.This string is exactly 32 bytes.This string is exactly 32 by"; + const Vector errors = BKE_path_apply_template(path, FILE_MAX, variables); + + EXPECT_TRUE(errors.is_empty()); + EXPECT_EQ(blender::StringRef(path), blender::StringRef(result)); + } +} + +} // namespace blender::bke::tests diff --git a/source/blender/blenloader/intern/versioning_450.cc b/source/blender/blenloader/intern/versioning_450.cc index 6738311413d..85fc68f6d43 100644 --- a/source/blender/blenloader/intern/versioning_450.cc +++ b/source/blender/blenloader/intern/versioning_450.cc @@ -3286,6 +3286,55 @@ static void do_version_alpha_over_node_options_to_inputs_animation(bNodeTree *no }); } +/* Turns all instances of "{" and "}" in a string into "{{" and "}}", escaping + * them for strings that are processed with templates so that they don't + * erroneously get interepreted as template expressions. */ +static void version_escape_curly_braces(char string[], const int string_array_length) +{ + int bytes_processed = 0; + while (bytes_processed < string_array_length && string[bytes_processed] != '\0') { + if (string[bytes_processed] == '{') { + BLI_string_replace_range( + string, string_array_length, bytes_processed, bytes_processed + 1, "{{"); + bytes_processed += 2; + continue; + } + if (string[bytes_processed] == '}') { + BLI_string_replace_range( + string, string_array_length, bytes_processed, bytes_processed + 1, "}}"); + bytes_processed += 2; + continue; + } + bytes_processed++; + } +} + +/* Escapes all instances of "{" and "}" in the paths in a compositor node tree's + * File Output nodes. + * + * If the passed node tree is not a compositor node tree, does nothing. */ +static void version_escape_curly_braces_in_compositor_file_output_nodes(bNodeTree &nodetree) +{ + if (nodetree.type != NTREE_COMPOSIT) { + return; + } + + LISTBASE_FOREACH (bNode *, node, &nodetree.nodes) { + if (strcmp(node->idname, "CompositorNodeOutputFile") != 0) { + continue; + } + + NodeImageMultiFile *node_data = static_cast(node->storage); + version_escape_curly_braces(node_data->base_path, FILE_MAX); + + LISTBASE_FOREACH (bNodeSocket *, sock, &node->inputs) { + NodeImageMultiFileSocket *socket_data = static_cast( + sock->storage); + version_escape_curly_braces(socket_data->path, FILE_MAX); + } + } +} + void do_versions_after_linking_450(FileData * /*fd*/, Main *bmain) { if (!MAIN_VERSION_FILE_ATLEAST(bmain, 405, 8)) { @@ -4861,6 +4910,23 @@ void blo_do_versions_450(FileData * /*fd*/, Library * /*lib*/, Main *bmain) } } + if (!MAIN_VERSION_FILE_ATLEAST(bmain, 405, 67)) { + /* Version render output paths (both primary on scene as well as those in + * the File Output compositor node) to escape curly braces. */ + { + LISTBASE_FOREACH (Scene *, scene, &bmain->scenes) { + version_escape_curly_braces(scene->r.pic, FILE_MAX); + if (scene->nodetree) { + version_escape_curly_braces_in_compositor_file_output_nodes(*scene->nodetree); + } + } + + LISTBASE_FOREACH (bNodeTree *, nodetree, &bmain->nodetrees) { + version_escape_curly_braces_in_compositor_file_output_nodes(*nodetree); + } + } + } + /* Always run this versioning (keep at the bottom of the function). Meshes are written with the * legacy format which always needs to be converted to the new format on file load. To be moved * to a subversion check in 5.0. */ diff --git a/source/blender/editors/interface/interface_layout.cc b/source/blender/editors/interface/interface_layout.cc index 71a361b2bc2..6581127e115 100644 --- a/source/blender/editors/interface/interface_layout.cc +++ b/source/blender/editors/interface/interface_layout.cc @@ -32,6 +32,7 @@ #include "BKE_global.hh" #include "BKE_idprop.hh" #include "BKE_lib_id.hh" +#include "BKE_path_templates.hh" #include "BKE_screen.hh" #include "RNA_access.hh" @@ -1112,6 +1113,20 @@ static uiBut *ui_item_with_label(uiLayout *layout, but = uiDefAutoButR(block, ptr, prop, index, str, icon, x, y, prop_but_width, h); } + /* Highlight in red on path template validity errors. */ + if (but != nullptr && ELEM(but->type, UI_BTYPE_TEXT)) { + /* We include PROP_NONE here because some plain string properties are used + * as parts of paths. For example, the sub-paths in the compositor's File + * Output node. */ + if (ELEM(subtype, PROP_FILEPATH, PROP_DIRPATH, PROP_NONE)) { + if ((RNA_property_flag(prop) & PROP_PATH_SUPPORTS_TEMPLATES) != 0) { + if (!BKE_validate_template_syntax(but->drawstr.c_str()).is_empty()) { + UI_but_flag_enable(but, UI_BUT_REDALERT); + } + } + } + } + if (flag & UI_ITEM_R_IMMEDIATE) { UI_but_flag_enable(but, UI_BUT_ACTIVATE_ON_INIT); } diff --git a/source/blender/editors/interface/regions/interface_region_tooltip.cc b/source/blender/editors/interface/regions/interface_region_tooltip.cc index 549c345bfeb..772f26657af 100644 --- a/source/blender/editors/interface/regions/interface_region_tooltip.cc +++ b/source/blender/editors/interface/regions/interface_region_tooltip.cc @@ -46,6 +46,7 @@ #include "BKE_library.hh" #include "BKE_main.hh" #include "BKE_paint.hh" +#include "BKE_path_templates.hh" #include "BKE_screen.hh" #include "BKE_vfont.hh" @@ -1043,13 +1044,15 @@ static std::unique_ptr ui_tooltip_data_from_button_or_extra_icon( } } - /* Warn if relative paths are used when unsupported (will already display red-alert). */ + /* Warn on path validity errors. */ if (ELEM(but->type, UI_BTYPE_TEXT) && /* Check red-alert, if the flag is not set, then this was suppressed. */ (but->flag & UI_BUT_REDALERT)) { if (rnaprop) { PropertySubType subtype = RNA_property_subtype(rnaprop); + + /* If relative paths are used when unsupported (will already display red-alert). */ if (ELEM(subtype, PROP_FILEPATH, PROP_DIRPATH)) { if ((RNA_property_flag(rnaprop) & PROP_PATH_SUPPORTS_BLEND_RELATIVE) == 0) { if (BLI_path_is_rel(but->drawstr.c_str())) { @@ -1062,6 +1065,27 @@ static std::unique_ptr ui_tooltip_data_from_button_or_extra_icon( } } } + + /* We include PROP_NONE here because some plain string properties are used + * as parts of paths. For example, the sub-paths in the compositor's File + * Output node. */ + if (ELEM(subtype, PROP_FILEPATH, PROP_DIRPATH, PROP_NONE)) { + /* Template parse errors, for paths that support it. */ + if ((RNA_property_flag(rnaprop) & PROP_PATH_SUPPORTS_TEMPLATES) != 0) { + const blender::StringRef path = but->drawstr; + const blender::Vector errors = + BKE_validate_template_syntax(path); + + if (!errors.is_empty()) { + std::string error_message("Syntax error(s):"); + for (const blender::bke::path_templates::Error &error : errors) { + error_message += "\n - " + BKE_path_template_error_to_string(error, path); + } + UI_tooltip_text_field_add( + *data, error_message, {}, UI_TIP_STYLE_NORMAL, UI_TIP_LC_ALERT); + } + } + } } } diff --git a/source/blender/editors/object/object_bake_api.cc b/source/blender/editors/object/object_bake_api.cc index ec5f86eb0fd..a66e5076837 100644 --- a/source/blender/editors/object/object_bake_api.cc +++ b/source/blender/editors/object/object_bake_api.cc @@ -924,14 +924,19 @@ static bool bake_targets_output_external(const BakeAPIRender *bkr, BakeData *bake = &bkr->scene->r.bake; char filepath[FILE_MAX]; - BKE_image_path_from_imtype(filepath, - bkr->filepath, - BKE_main_blendfile_path(bkr->main), - 0, - bake->im_format.imtype, - true, - false, - nullptr); + const blender::Vector errors = BKE_image_path_from_imtype( + filepath, + bkr->filepath, + BKE_main_blendfile_path(bkr->main), + nullptr, + 0, + bake->im_format.imtype, + true, + false, + nullptr); + BLI_assert_msg(errors.is_empty(), + "Path parsing errors should only occur when a variable map is provided."); + UNUSED_VARS_NDEBUG(errors); if (bkr->is_automatic_name) { BLI_path_suffix(filepath, FILE_MAX, ob->id.name + 2, "_"); diff --git a/source/blender/editors/render/render_opengl.cc b/source/blender/editors/render/render_opengl.cc index ea8c06be2a3..50a6edecec4 100644 --- a/source/blender/editors/render/render_opengl.cc +++ b/source/blender/editors/render/render_opengl.cc @@ -80,6 +80,8 @@ #include "render_intern.hh" +namespace path_templates = blender::bke::path_templates; + /* TODO(sergey): Find better approximation of the scheduled frames. * For really high-resolution renders it might fail still. */ #define MAX_SCHEDULED_FRAMES 8 @@ -415,20 +417,32 @@ static void screen_opengl_render_write(OGLRender *oglrender) rr = RE_AcquireResultRead(oglrender->re); - BKE_image_path_from_imformat(filepath, - scene->r.pic, - BKE_main_blendfile_path(oglrender->bmain), - scene->r.cfra, - &scene->r.im_format, - (scene->r.scemode & R_EXTENSION) != 0, - false, - nullptr); + const char *relbase = BKE_main_blendfile_path(oglrender->bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables(relbase, + &scene->r); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath, + scene->r.pic, + relbase, + &template_variables, + scene->r.cfra, + &scene->r.im_format, + (scene->r.scemode & R_EXTENSION) != 0, + false, + nullptr); - /* write images as individual images or stereo */ - BKE_render_result_stamp_info(scene, scene->camera, rr, false); - ok = BKE_image_render_write(oglrender->reports, rr, scene, false, filepath); + if (!errors.is_empty()) { + std::unique_lock lock(oglrender->reports_mutex); + BKE_report_path_template_errors(oglrender->reports, RPT_ERROR, scene->r.pic, errors); + ok = false; + } + else { + /* write images as individual images or stereo */ + BKE_render_result_stamp_info(scene, scene->camera, rr, false); + ok = BKE_image_render_write(oglrender->reports, rr, scene, false, filepath); - RE_ReleaseResultImage(oglrender->re); + RE_ReleaseResultImage(oglrender->re); + } if (ok) { printf("OpenGL Render written to '%s'\n", filepath); @@ -1032,17 +1046,29 @@ static void write_result(TaskPool *__restrict pool, WriteTaskData *task_data) * calculate file name again here. */ char filepath[FILE_MAX]; - BKE_image_path_from_imformat(filepath, - scene->r.pic, - BKE_main_blendfile_path(oglrender->bmain), - cfra, - &scene->r.im_format, - (scene->r.scemode & R_EXTENSION) != 0, - true, - nullptr); + const char *relbase = BKE_main_blendfile_path(oglrender->bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables(relbase, + &scene->r); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath, + scene->r.pic, + relbase, + &template_variables, + cfra, + &scene->r.im_format, + (scene->r.scemode & R_EXTENSION) != 0, + true, + nullptr); + + if (!errors.is_empty()) { + BKE_report_path_template_errors(&reports, RPT_ERROR, scene->r.pic, errors); + ok = false; + } + else { + BKE_render_result_stamp_info(scene, scene->camera, rr, false); + ok = BKE_image_render_write(nullptr, rr, scene, true, filepath); + } - BKE_render_result_stamp_info(scene, scene->camera, rr, false); - ok = BKE_image_render_write(nullptr, rr, scene, true, filepath); if (!ok) { BKE_reportf(&reports, RPT_ERROR, "Write error: cannot save %s", filepath); } @@ -1121,16 +1147,26 @@ static bool screen_opengl_render_anim_step(OGLRender *oglrender) is_movie = BKE_imtype_is_movie(scene->r.im_format.imtype); if (!is_movie) { - BKE_image_path_from_imformat(filepath, - scene->r.pic, - BKE_main_blendfile_path(oglrender->bmain), - scene->r.cfra, - &scene->r.im_format, - (scene->r.scemode & R_EXTENSION) != 0, - true, - nullptr); + const char *relbase = BKE_main_blendfile_path(oglrender->bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables(relbase, + &scene->r); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath, + scene->r.pic, + relbase, + &template_variables, + scene->r.cfra, + &scene->r.im_format, + (scene->r.scemode & R_EXTENSION) != 0, + true, + nullptr); - if ((scene->r.mode & R_NO_OVERWRITE) && BLI_exists(filepath)) { + if (!errors.is_empty()) { + std::unique_lock lock(oglrender->reports_mutex); + BKE_report_path_template_errors(oglrender->reports, RPT_ERROR, scene->r.pic, errors); + ok = false; + } + else if ((scene->r.mode & R_NO_OVERWRITE) && BLI_exists(filepath)) { { std::unique_lock lock(oglrender->reports_mutex); BKE_reportf(oglrender->reports, RPT_INFO, "Skipping existing frame \"%s\"", filepath); diff --git a/source/blender/imbuf/movie/MOV_write.hh b/source/blender/imbuf/movie/MOV_write.hh index 77831c393c3..60f002d1ec9 100644 --- a/source/blender/imbuf/movie/MOV_write.hh +++ b/source/blender/imbuf/movie/MOV_write.hh @@ -38,4 +38,5 @@ void MOV_write_end(MovieWriter *writer); void MOV_filepath_from_settings(char filepath[/*FILE_MAX*/ 1024], const RenderData *rd, bool preview, - const char *suffix); + const char *suffix, + ReportList *reports); diff --git a/source/blender/imbuf/movie/intern/movie_write.cc b/source/blender/imbuf/movie/intern/movie_write.cc index bede84ccc75..a343a656dce 100644 --- a/source/blender/imbuf/movie/intern/movie_write.cc +++ b/source/blender/imbuf/movie/intern/movie_write.cc @@ -32,6 +32,7 @@ # include "BKE_global.hh" # include "BKE_image.hh" # include "BKE_main.hh" +# include "BKE_path_templates.hh" # include "IMB_imbuf.hh" @@ -53,11 +54,12 @@ static void ffmpeg_dict_set_int(AVDictionary **dict, const char *key, int value) } static void ffmpeg_movie_close(MovieWriter *context); -static void ffmpeg_filepath_get(MovieWriter *context, +static bool ffmpeg_filepath_get(MovieWriter *context, char filepath[FILE_MAX], const RenderData *rd, bool preview, - const char *suffix); + const char *suffix, + ReportList *reports); static AVFrame *alloc_frame(AVPixelFormat pix_fmt, int width, int height) { @@ -1015,7 +1017,9 @@ static bool start_ffmpeg_impl(MovieWriter *context, } /* Determine the correct filename */ - ffmpeg_filepath_get(context, filepath, rd, context->ffmpeg_preview, suffix); + if (!ffmpeg_filepath_get(context, filepath, rd, context->ffmpeg_preview, suffix, reports)) { + return false; + } FF_DEBUG_PRINT( "ffmpeg: starting output to %s:\n" " type=%d, codec=%d, audio_codec=%d,\n" @@ -1248,12 +1252,19 @@ static void flush_delayed_frames(AVCodecContext *c, AVStream *stream, AVFormatCo av_packet_free(&packet); } -/* Get the output filename-- similar to the other output formats */ -static void ffmpeg_filepath_get(MovieWriter *context, +/** + * Get the output filename-- similar to the other output formats. + * + * \param reports If non-null, will report errors with `RPT_ERROR` level reports. + * + * \return true on success, false on failure due to errors. + */ +static bool ffmpeg_filepath_get(MovieWriter *context, char filepath[FILE_MAX], const RenderData *rd, bool preview, - const char *suffix) + const char *suffix, + ReportList *reports) { char autosplit[20]; @@ -1262,7 +1273,7 @@ static void ffmpeg_filepath_get(MovieWriter *context, int sfra, efra; if (!filepath || !exts) { - return; + return false; } if (preview) { @@ -1275,6 +1286,14 @@ static void ffmpeg_filepath_get(MovieWriter *context, } BLI_strncpy(filepath, rd->pic, FILE_MAX); + + const blender::Vector errors = BKE_path_apply_template( + filepath, FILE_MAX, BKE_build_template_variables(BKE_main_blendfile_path_from_global(), rd)); + if (!errors.is_empty()) { + BKE_report_path_template_errors(reports, RPT_ERROR, filepath, errors); + return false; + } + BLI_path_abs(filepath, BKE_main_blendfile_path_from_global()); BLI_file_ensure_parent_dir_exists(filepath); @@ -1316,14 +1335,17 @@ static void ffmpeg_filepath_get(MovieWriter *context, } BLI_path_suffix(filepath, FILE_MAX, suffix, ""); + + return true; } static void ffmpeg_get_filepath(char filepath[/*FILE_MAX*/ 1024], const RenderData *rd, bool preview, - const char *suffix) + const char *suffix, + ReportList *reports) { - ffmpeg_filepath_get(nullptr, filepath, rd, preview, suffix); + ffmpeg_filepath_get(nullptr, filepath, rd, preview, suffix, reports); } static MovieWriter *ffmpeg_movie_open(const Scene *scene, @@ -1552,15 +1574,16 @@ void MOV_write_end(MovieWriter *writer) void MOV_filepath_from_settings(char filepath[/*FILE_MAX*/ 1024], const RenderData *rd, bool preview, - const char *suffix) + const char *suffix, + ReportList *reports) { #ifdef WITH_FFMPEG if (is_imtype_ffmpeg(rd->im_format.imtype)) { - ffmpeg_get_filepath(filepath, rd, preview, suffix); + ffmpeg_get_filepath(filepath, rd, preview, suffix, reports); return; } #else - UNUSED_VARS(rd, preview, suffix); + UNUSED_VARS(rd, preview, suffix, reports); #endif filepath[0] = '\0'; } diff --git a/source/blender/makesrna/RNA_types.hh b/source/blender/makesrna/RNA_types.hh index 2048249724d..23925b6582f 100644 --- a/source/blender/makesrna/RNA_types.hh +++ b/source/blender/makesrna/RNA_types.hh @@ -282,7 +282,7 @@ enum PropertySubType { /* Make sure enums are updated with these */ /* HIGHEST FLAG IN USE: 1u << 31 - * FREE FLAGS: 13, 14. */ + * FREE FLAGS: 13. */ enum PropertyFlag { /** * Editable means the property is editable in the user @@ -429,6 +429,11 @@ enum PropertyFlag { */ PROP_PATH_SUPPORTS_BLEND_RELATIVE = (1 << 15), + /** + * Paths that are evaluated with templating. + */ + PROP_PATH_SUPPORTS_TEMPLATES = (1 << 14), + /** Do not write in presets (#PROP_HIDDEN and #PROP_SKIP_SAVE won't either). */ PROP_SKIP_PRESET = (1 << 11), }; diff --git a/source/blender/makesrna/intern/rna_nodetree.cc b/source/blender/makesrna/intern/rna_nodetree.cc index c4edb2a9e21..ffe05b76287 100644 --- a/source/blender/makesrna/intern/rna_nodetree.cc +++ b/source/blender/makesrna/intern/rna_nodetree.cc @@ -7724,6 +7724,7 @@ static void rna_def_cmp_output_file_slot_file(BlenderRNA *brna) RNA_def_struct_name_property(srna, prop); RNA_def_property_ui_text(prop, "Path", "Subpath used for this slot"); RNA_def_property_translation_context(prop, BLT_I18NCONTEXT_EDITOR_FILEBROWSER); + RNA_def_property_flag(prop, PROP_PATH_OUTPUT | PROP_PATH_SUPPORTS_TEMPLATES); RNA_def_property_update(prop, NC_NODE | NA_EDITED, nullptr); } static void rna_def_cmp_output_file_slot_layer(BlenderRNA *brna) @@ -7797,7 +7798,8 @@ static void def_cmp_output_file(BlenderRNA *brna, StructRNA *srna) prop = RNA_def_property(srna, "base_path", PROP_STRING, PROP_FILEPATH); RNA_def_property_string_sdna(prop, nullptr, "base_path"); RNA_def_property_ui_text(prop, "Base Path", "Base output path for the image"); - RNA_def_property_flag(prop, PROP_PATH_OUTPUT | PROP_PATH_SUPPORTS_BLEND_RELATIVE); + RNA_def_property_flag( + prop, PROP_PATH_OUTPUT | PROP_PATH_SUPPORTS_BLEND_RELATIVE | PROP_PATH_SUPPORTS_TEMPLATES); RNA_def_property_update(prop, NC_NODE | NA_EDITED, "rna_Node_update"); prop = RNA_def_property(srna, "active_input_index", PROP_INT, PROP_NONE); diff --git a/source/blender/makesrna/intern/rna_rna.cc b/source/blender/makesrna/intern/rna_rna.cc index 1b15b18b3c2..2f2e1710e44 100644 --- a/source/blender/makesrna/intern/rna_rna.cc +++ b/source/blender/makesrna/intern/rna_rna.cc @@ -175,6 +175,9 @@ static constexpr auto PROP_PATH_OUTPUT_DESCR = ""; static constexpr auto PROP_PATH_RELATIVE_DESCR = "This path supports relative prefix \"//\" which is expanded the the directory " "where the current \".blend\" file is located."; +static constexpr auto PROP_PATH_SUPPORTS_TEMPLATES_DESCR = + "This path supports the \"{variable_name}\" template syntax, which substitutes the " + "value of the referenced variable in place of the template expression"; static constexpr auto PROP_ENUM_FLAG_DESCR = ""; const EnumPropertyItem rna_enum_property_flag_items[] = { @@ -199,6 +202,11 @@ const EnumPropertyItem rna_enum_property_flag_items[] = { 0, "Relative Path Support", PROP_PATH_RELATIVE_DESCR}, + {PROP_PATH_SUPPORTS_TEMPLATES, + "SUPPORTS_TEMPLATES", + 0, + "Variable expression support", + PROP_PATH_SUPPORTS_TEMPLATES_DESCR}, {0, nullptr, 0, nullptr, nullptr}, }; @@ -801,6 +809,12 @@ static bool rna_Property_is_path_supports_blend_relative_flag_get(PointerRNA *pt return (prop->flag & PROP_PATH_SUPPORTS_BLEND_RELATIVE) != 0; } +static bool rna_Property_is_path_supports_templates_flag_get(PointerRNA *ptr) +{ + PropertyRNA *prop = (PropertyRNA *)ptr->data; + return (prop->flag & PROP_PATH_SUPPORTS_TEMPLATES) != 0; +} + static int rna_Property_tags_get(PointerRNA *ptr) { return RNA_property_tags(static_cast(ptr->data)); @@ -3314,6 +3328,16 @@ static void rna_def_property(BlenderRNA *brna) "Property is a path which supports the \"//\" prefix, " "signifying the location as relative to the \".blend\" files directory"); + prop = RNA_def_property(srna, "is_path_supports_templates", PROP_BOOLEAN, PROP_NONE); + RNA_def_property_clear_flag(prop, PROP_EDITABLE); + RNA_def_property_boolean_funcs( + prop, "rna_Property_is_path_supports_templates_flag_get", nullptr); + RNA_def_property_ui_text( + prop, + "Variable Expression Support", + "Property is a path which supports the \"{variable_name}\" variable expression syntax, " + "which substitutes the value of the referenced variable in place of the expression"); + prop = RNA_def_property(srna, "tags", PROP_ENUM, PROP_NONE); RNA_def_property_clear_flag(prop, PROP_EDITABLE); RNA_def_property_enum_items(prop, dummy_prop_tags); diff --git a/source/blender/makesrna/intern/rna_scene.cc b/source/blender/makesrna/intern/rna_scene.cc index ab0fae0271a..a61aa2ec913 100644 --- a/source/blender/makesrna/intern/rna_scene.cc +++ b/source/blender/makesrna/intern/rna_scene.cc @@ -7251,7 +7251,8 @@ static void rna_def_scene_render_data(BlenderRNA *brna) "Output Path", "Directory/name to save animations, # characters define the position " "and padding of frame numbers"); - RNA_def_property_flag(prop, PROP_PATH_OUTPUT | PROP_PATH_SUPPORTS_BLEND_RELATIVE); + RNA_def_property_flag( + prop, PROP_PATH_OUTPUT | PROP_PATH_SUPPORTS_BLEND_RELATIVE | PROP_PATH_SUPPORTS_TEMPLATES); RNA_def_property_update(prop, NC_SCENE | ND_RENDER_OPTIONS, nullptr); /* Render result EXR cache. */ diff --git a/source/blender/makesrna/intern/rna_scene_api.cc b/source/blender/makesrna/intern/rna_scene_api.cc index 86b5ed5c25c..cd915c2cf29 100644 --- a/source/blender/makesrna/intern/rna_scene_api.cc +++ b/source/blender/makesrna/intern/rna_scene_api.cc @@ -97,9 +97,15 @@ static void rna_Scene_uvedit_aspect(Scene * /*scene*/, Object *ob, float aspect[ aspect[0] = aspect[1] = 1.0f; } -static void rna_SceneRender_get_frame_path( - RenderData *rd, Main *bmain, int frame, bool preview, const char *view, char *filepath) +static void rna_SceneRender_get_frame_path(RenderData *rd, + Main *bmain, + ReportList *reports, + int frame, + bool preview, + const char *view, + char *filepath) { + const char *suffix = BKE_scene_multiview_view_suffix_get(rd, view); /* avoid nullptr pointer */ @@ -108,17 +114,27 @@ static void rna_SceneRender_get_frame_path( } if (BKE_imtype_is_movie(rd->im_format.imtype)) { - MOV_filepath_from_settings(filepath, rd, preview != 0, suffix); + MOV_filepath_from_settings(filepath, rd, preview != 0, suffix, reports); } else { - BKE_image_path_from_imformat(filepath, - rd->pic, - BKE_main_blendfile_path(bmain), - (frame == INT_MIN) ? rd->cfra : frame, - &rd->im_format, - (rd->scemode & R_EXTENSION) != 0, - true, - suffix); + const char *relbase = BKE_main_blendfile_path(bmain); + const blender::bke::path_templates::VariableMap template_variables = + BKE_build_template_variables(relbase, rd); + + const blender::Vector errors = + BKE_image_path_from_imformat(filepath, + rd->pic, + relbase, + &template_variables, + (frame == INT_MIN) ? rd->cfra : frame, + &rd->im_format, + (rd->scemode & R_EXTENSION) != 0, + true, + suffix); + + if (!errors.is_empty()) { + BKE_report_path_template_errors(reports, RPT_ERROR, rd->pic, errors); + } } } @@ -424,7 +440,7 @@ void RNA_api_scene_render(StructRNA *srna) PropertyRNA *parm; func = RNA_def_function(srna, "frame_path", "rna_SceneRender_get_frame_path"); - RNA_def_function_flag(func, FUNC_USE_MAIN); + RNA_def_function_flag(func, FUNC_USE_MAIN | FUNC_USE_REPORTS); RNA_def_function_ui_description( func, "Return the absolute path to the filename to be written for a given frame"); RNA_def_int(func, diff --git a/source/blender/nodes/composite/nodes/node_composite_file_output.cc b/source/blender/nodes/composite/nodes/node_composite_file_output.cc index 40bd8f8f4ee..1adea015258 100644 --- a/source/blender/nodes/composite/nodes/node_composite_file_output.cc +++ b/source/blender/nodes/composite/nodes/node_composite_file_output.cc @@ -53,6 +53,8 @@ #include "node_composite_util.hh" +namespace path_templates = blender::bke::path_templates; + /* **************** OUTPUT FILE ******************** */ /* find unique path */ @@ -539,7 +541,14 @@ class FileOutputOperation : public NodeOperation { char base_path[FILE_MAX]; const auto &socket = *static_cast(input->storage); - get_single_layer_image_base_path(socket.path, base_path); + + if (!get_single_layer_image_base_path(socket.path, base_path)) { + /* TODO: propagate this error to the render pipeline and UI. */ + BKE_report(nullptr, + RPT_ERROR, + "Invalid path template in File Output node. Skipping writing file."); + continue; + } /* The image saving code expects EXR images to have a different structure than standard * images. In particular, in EXR images, the buffers need to be stored in passes that are, in @@ -584,7 +593,11 @@ class FileOutputOperation : public NodeOperation { * name does not contain a view suffix. */ char image_path[FILE_MAX]; const char *path_view = has_views ? "" : context().get_view_name().data(); - get_multi_layer_exr_image_path(base_path, path_view, image_path); + + if (!get_multi_layer_exr_image_path(base_path, path_view, false, image_path)) { + BLI_assert_unreachable(); + return; + } const int2 size = result.domain().size; FileOutput &file_output = context().render_context()->get_file_output( @@ -612,7 +625,12 @@ class FileOutputOperation : public NodeOperation { * sure the file name does not contain a view suffix. */ char image_path[FILE_MAX]; const char *write_view = store_views_in_single_file ? "" : view; - get_multi_layer_exr_image_path(get_base_path(), write_view, image_path); + if (!get_multi_layer_exr_image_path(get_base_path(), write_view, true, image_path)) { + /* TODO: propagate this error to the render pipeline and UI. */ + BKE_report( + nullptr, RPT_ERROR, "Invalid path template in File Output node. Skipping writing file."); + return; + } const int2 size = compute_domain().size; const ImageFormatData format = node_storage(bnode()).format; @@ -845,29 +863,76 @@ class FileOutputOperation : public NodeOperation { } } - /* Get the base path of the image to be saved, based on the base path of the node. The base name - * is an optional initial name of the image, which will later be concatenated with other - * information like the frame number, view, and extension. If the base name is empty, then the - * base path represents a directory, so a trailing slash is ensured. */ - void get_single_layer_image_base_path(const char *base_name, char *base_path) + /** + * Get the base path of the image to be saved, based on the base path of the + * node. The base name is an optional initial name of the image, which will + * later be concatenated with other information like the frame number, view, + * and extension. If the base name is empty, then the base path represents a + * directory, so a trailing slash is ensured. + * + * Note: this takes care of path template expansion as well. + * + * If there are any errors processing the path, `bath_base` will be set to an + * empty string. + * + * \return True on success, false if there were any errors processing the + * path. + */ + bool get_single_layer_image_base_path(const char *base_name, char *r_base_path) { + const path_templates::VariableMap template_variables = BKE_build_template_variables( + BKE_main_blendfile_path_from_global(), &context().get_render_data()); + + /* Do template expansion on the node's base path. */ + char node_base_path[FILE_MAX] = ""; + BLI_strncpy(node_base_path, get_base_path(), FILE_MAX); + { + blender::Vector errors = BKE_path_apply_template( + node_base_path, FILE_MAX, template_variables); + if (!errors.is_empty()) { + r_base_path[0] = '\0'; + return false; + } + } + if (base_name[0]) { - BLI_path_join(base_path, FILE_MAX, get_base_path(), base_name); + /* Do template expansion on the socket's sub path ("base name"). */ + char sub_path[FILE_MAX] = ""; + BLI_strncpy(sub_path, base_name, FILE_MAX); + { + blender::Vector errors = BKE_path_apply_template( + sub_path, FILE_MAX, template_variables); + if (!errors.is_empty()) { + r_base_path[0] = '\0'; + return false; + } + } + + /* Combine the base path and sub path. */ + BLI_path_join(r_base_path, FILE_MAX, node_base_path, sub_path); } else { - BLI_strncpy(base_path, get_base_path(), FILE_MAX); - BLI_path_slash_ensure(base_path, FILE_MAX); + /* Just use the base path, as a directory. */ + BLI_strncpy(r_base_path, node_base_path, FILE_MAX); + BLI_path_slash_ensure(r_base_path, FILE_MAX); } + + return true; } /* Get the path of the image to be saved based on the given format. */ void get_single_layer_image_path(const char *base_path, const ImageFormatData &format, - char *image_path) + char *r_image_path) { - BKE_image_path_from_imformat(image_path, + BKE_image_path_from_imformat(r_image_path, base_path, BKE_main_blendfile_path_from_global(), + /* No variables, because path templating is + * already done by + * `get_single_layer_image_base_path()` before + * this is called. */ + nullptr, context().get_frame_number(), &format, use_file_extension(), @@ -875,19 +940,47 @@ class FileOutputOperation : public NodeOperation { nullptr); } - /* Get the path of the EXR image to be saved. If the given view is not empty, its corresponding - * file suffix will be appended to the name. */ - void get_multi_layer_exr_image_path(const char *base_path, const char *view, char *image_path) + /** + * Get the path of the EXR image to be saved. If the given view is not empty, + * its corresponding file suffix will be appended to the name. + * + * If there are any errors processing the path, the resulting path will be + * empty. + * + * \param apply_template Whether to run templating on the path or not. This is + * needed because this function is called from more than one place, some of + * which have already applied templating to the path and some of which + * haven't. Double-applying templating can give incorrect results. + * + * \return True on success, false if there were any errors processing the + * path. + */ + bool get_multi_layer_exr_image_path(const char *base_path, + const char *view, + const bool apply_template, + char *r_image_path) { - const char *suffix = BKE_scene_multiview_view_suffix_get(&context().get_render_data(), view); - BKE_image_path_from_imtype(image_path, - base_path, - BKE_main_blendfile_path_from_global(), - context().get_frame_number(), - R_IMF_IMTYPE_MULTILAYER, - use_file_extension(), - true, - suffix); + const RenderData &render_data = context().get_render_data(); + const char *suffix = BKE_scene_multiview_view_suffix_get(&render_data, view); + const char *relbase = BKE_main_blendfile_path_from_global(); + const path_templates::VariableMap template_variables = BKE_build_template_variables( + relbase, &render_data); + blender::Vector errors = BKE_image_path_from_imtype( + r_image_path, + base_path, + relbase, + apply_template ? &template_variables : nullptr, + context().get_frame_number(), + R_IMF_IMTYPE_MULTILAYER, + use_file_extension(), + true, + suffix); + + if (!errors.is_empty()) { + r_image_path[0] = '\0'; + } + + return errors.is_empty(); } bool is_multi_layer() diff --git a/source/blender/render/intern/pipeline.cc b/source/blender/render/intern/pipeline.cc index 7750818f0c5..1a4036b3679 100644 --- a/source/blender/render/intern/pipeline.cc +++ b/source/blender/render/intern/pipeline.cc @@ -100,6 +100,8 @@ #include "render_result.h" #include "render_types.h" +namespace path_templates = blender::bke::path_templates; + /* render flow * * 1) Initialize state @@ -2088,15 +2090,26 @@ void RE_RenderFrame(Render *re, } else { char filepath_override[FILE_MAX]; - BKE_image_path_from_imformat(filepath_override, - rd.pic, - BKE_main_blendfile_path(bmain), - scene->r.cfra, - &rd.im_format, - (rd.scemode & R_EXTENSION) != 0, - false, - nullptr); - do_write_image_or_movie(re, bmain, scene, 0, filepath_override); + const char *relbase = BKE_main_blendfile_path(bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables( + relbase, &scene->r); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath_override, + rd.pic, + relbase, + &template_variables, + scene->r.cfra, + &rd.im_format, + (rd.scemode & R_EXTENSION) != 0, + false, + nullptr); + + if (errors.is_empty()) { + do_write_image_or_movie(re, bmain, scene, 0, filepath_override); + } + else { + BKE_report_path_template_errors(re->reports, RPT_ERROR, rd.pic, errors); + } } } @@ -2309,18 +2322,29 @@ static bool do_write_image_or_movie( STRNCPY(filepath, filepath_override); } else { - BKE_image_path_from_imformat(filepath, - scene->r.pic, - BKE_main_blendfile_path(bmain), - scene->r.cfra, - &scene->r.im_format, - (scene->r.scemode & R_EXTENSION) != 0, - true, - nullptr); + const char *relbase = BKE_main_blendfile_path(bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables( + relbase, &scene->r); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath, + scene->r.pic, + relbase, + &template_variables, + scene->r.cfra, + &scene->r.im_format, + (scene->r.scemode & R_EXTENSION) != 0, + true, + nullptr); + if (!errors.is_empty()) { + BKE_report_path_template_errors(re->reports, RPT_ERROR, scene->r.pic, errors); + ok = false; + } } /* write images as individual images or stereo */ - ok = BKE_image_render_write(re->reports, &rres, scene, true, filepath); + if (ok) { + ok = BKE_image_render_write(re->reports, &rres, scene, true, filepath); + } } RE_ReleaseResultImageViews(re, &rres); @@ -2332,7 +2356,7 @@ static bool do_write_image_or_movie( BLI_timecode_string_from_time_simple(filepath, sizeof(filepath), re->i.lastframetime); std::string message = fmt::format("Time: {}", filepath); - if (do_write_file) { + if (do_write_file && ok) { BLI_timecode_string_from_time_simple( filepath, sizeof(filepath), re->i.lastframetime - render_time); message = fmt::format("{} (Saving: {})", message, filepath); @@ -2501,15 +2525,28 @@ void RE_RenderAnim(Render *re, /* Touch/NoOverwrite options are only valid for image's */ if (is_movie == false && do_write_file) { - if (rd.mode & (R_NO_OVERWRITE | R_TOUCH)) { - BKE_image_path_from_imformat(filepath, - rd.pic, - BKE_main_blendfile_path(bmain), - scene->r.cfra, - &rd.im_format, - (rd.scemode & R_EXTENSION) != 0, - true, - nullptr); + const char *relbase = BKE_main_blendfile_path(bmain); + const path_templates::VariableMap template_variables = BKE_build_template_variables(relbase, + &rd); + const blender::Vector errors = BKE_image_path_from_imformat( + filepath, + rd.pic, + BKE_main_blendfile_path(bmain), + &template_variables, + scene->r.cfra, + &rd.im_format, + (rd.scemode & R_EXTENSION) != 0, + true, + nullptr); + + /* The filepath cannot be parsed, so we can't save the renders anywhere. + * So we just cancel. */ + if (!errors.is_empty()) { + BKE_report_path_template_errors(re->reports, RPT_ERROR, rd.pic, errors); + /* We have to set the `is_break` flag here so that final cleanup code + * recognizes that the render has failed. */ + G.is_break = true; + break; } if (rd.mode & R_NO_OVERWRITE) { diff --git a/source/creator/creator_args.cc b/source/creator/creator_args.cc index 4b598b5f412..f15f8f8480f 100644 --- a/source/creator/creator_args.cc +++ b/source/creator/creator_args.cc @@ -1919,6 +1919,9 @@ static const char arg_handle_output_set_doc[] = "\tSet the render path and file name.\n" "\tUse '//' at the start of the path to render relative to the blend-file.\n" "\n" + "\tYou can use path templating features such as '{blend_name}' in the path.\n" + "\tSee Blender's documentation on path templates for more details.\n" + "\n" "\tThe '#' characters are replaced by the frame number, and used to define zero padding.\n" "\n" "\t* 'animation_##_test.png' becomes 'animation_01_test.png'\n"