Skip to content

Commit 99ddfe0

Browse files
committed
Add XMP standard-namespace policy and transfer gate
Introduce XmpExistingStandardNamespacePolicy to control how existing standard portable XMP properties are reconciled (PreserveAll vs CanonicalizeManaged). Thread the new policy through XMP dumping and metadata transfer codepaths, Python bindings, CLI options, and the public C++/Python API. Implement logic to identify managed standard portable properties and optionally canonicalize (drop) them so EXIF/IPTC projections can be used instead. Add unit tests covering canonicalization behavior and update many Python/C++ wrappers to accept the new option. Also add a named transfer release gate (CMake target and tests/transfer_release_gate.cmake) to run core transfer-focused tests and optional Python smoke tests, update docs to describe the gate, and add a project logo image.
1 parent 6aaaede commit 99ddfe0

File tree

15 files changed

+517
-5
lines changed

15 files changed

+517
-5
lines changed

CMakeLists.txt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,50 @@ if(OPENMETA_BUILD_TESTS)
638638

639639
add_test(NAME openmeta_tests COMMAND openmeta_tests)
640640

641+
if(TARGET metatransfer AND TARGET openmeta_python)
642+
add_custom_target(openmeta_gate_transfer_release
643+
DEPENDS openmeta_tests metatransfer openmeta_python
644+
COMMAND ${CMAKE_COMMAND}
645+
"-DOPENMETA_TESTS_BIN=$<TARGET_FILE:openmeta_tests>"
646+
"-DMETATRANSFER_BIN=$<TARGET_FILE:metatransfer>"
647+
"-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/_transfer_release_gate"
648+
"-DOPENMETA_PYTHON_EXECUTABLE=${Python_EXECUTABLE}"
649+
"-DOPENMETA_PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}/python"
650+
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/transfer_release_gate.cmake"
651+
COMMENT "Running transfer release gate"
652+
VERBATIM
653+
)
654+
add_test(
655+
NAME openmeta_transfer_release_gate
656+
COMMAND ${CMAKE_COMMAND}
657+
"-DOPENMETA_TESTS_BIN=$<TARGET_FILE:openmeta_tests>"
658+
"-DMETATRANSFER_BIN=$<TARGET_FILE:metatransfer>"
659+
"-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/_transfer_release_gate"
660+
"-DOPENMETA_PYTHON_EXECUTABLE=${Python_EXECUTABLE}"
661+
"-DOPENMETA_PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}/python"
662+
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/transfer_release_gate.cmake"
663+
)
664+
elseif(TARGET metatransfer)
665+
add_custom_target(openmeta_gate_transfer_release
666+
DEPENDS openmeta_tests metatransfer
667+
COMMAND ${CMAKE_COMMAND}
668+
"-DOPENMETA_TESTS_BIN=$<TARGET_FILE:openmeta_tests>"
669+
"-DMETATRANSFER_BIN=$<TARGET_FILE:metatransfer>"
670+
"-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/_transfer_release_gate"
671+
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/transfer_release_gate.cmake"
672+
COMMENT "Running transfer release gate"
673+
VERBATIM
674+
)
675+
add_test(
676+
NAME openmeta_transfer_release_gate
677+
COMMAND ${CMAKE_COMMAND}
678+
"-DOPENMETA_TESTS_BIN=$<TARGET_FILE:openmeta_tests>"
679+
"-DMETATRANSFER_BIN=$<TARGET_FILE:metatransfer>"
680+
"-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/_transfer_release_gate"
681+
-P "${CMAKE_CURRENT_SOURCE_DIR}/tests/transfer_release_gate.cmake"
682+
)
683+
endif()
684+
641685
if(TARGET metadump AND TARGET thumdump)
642686
add_test(
643687
NAME openmeta_cli_preview_index

docs/development.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,23 @@ cmake --build build-tests --target openmeta_gate_python_metatransfer_edit_smoke
782782
ctest --test-dir build-tests -R openmeta_python_metatransfer_edit_smoke --output-on-failure
783783
```
784784

785+
Stronger transfer release gate:
786+
- in a non-Python test tree it runs:
787+
- `MetadataTransferApi.*`
788+
- `XmpDump.*`
789+
- `ExrAdapter.*`
790+
- `DngSdkAdapter.*`
791+
- `openmeta_cli_metatransfer_smoke`
792+
- in a Python-enabled test tree it also runs:
793+
- `openmeta_python_transfer_probe_smoke`
794+
- `openmeta_python_metatransfer_edit_smoke`
795+
796+
Build + run:
797+
```bash
798+
cmake --build build-tests --target openmeta_gate_transfer_release
799+
ctest --test-dir build-tests -R openmeta_transfer_release_gate --output-on-failure
800+
```
801+
785802
Coverage note:
786803
- Public tree tests focus on deterministic unit/fuzz/smoke behavior.
787804
- Corpus-scale compare/baseline workflows are external to the public tree and

docs/images/OpenMeta_Logo.png

44.8 KB
Loading

docs/metadata_transfer_plan.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ That does not make all targets equally mature, but it does mean the transfer
112112
core has real roundtrip regression gates across the primary supported export
113113
families.
114114

115+
There is now also a named in-tree transfer release gate:
116+
- `openmeta_gate_transfer_release`
117+
- `openmeta_transfer_release_gate`
118+
119+
In a non-Python test tree it runs:
120+
- `MetadataTransferApi.*`
121+
- `XmpDump.*`
122+
- `ExrAdapter.*`
123+
- `DngSdkAdapter.*`
124+
- `openmeta_cli_metatransfer_smoke`
125+
126+
In a Python-enabled test tree it also runs:
127+
- `openmeta_python_transfer_probe_smoke`
128+
- `openmeta_python_metatransfer_edit_smoke`
129+
115130
## Per-Target Notes
116131

117132
### JPEG

docs/sphinx/testing.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,30 @@ These gates provide fast regression checks for safe-output and validation
8484
behavior. Corpus-scale compare/baseline gates are expected to run in project CI
8585
or release validation workflows.
8686

87+
Transfer release gate
88+
---------------------
89+
90+
The stronger transfer release gate rolls up the main transfer-focused unit
91+
suite and the public transfer smoke coverage into one named check.
92+
93+
- In a non-Python test tree it runs:
94+
95+
- ``MetadataTransferApi.*``
96+
- ``XmpDump.*``
97+
- ``ExrAdapter.*``
98+
- ``DngSdkAdapter.*``
99+
- ``openmeta_cli_metatransfer_smoke``
100+
101+
- In a Python-enabled test tree it also runs:
102+
103+
- ``openmeta_python_transfer_probe_smoke``
104+
- ``openmeta_python_metatransfer_edit_smoke``
105+
106+
.. code-block:: bash
107+
108+
cmake --build build-tests --target openmeta_gate_transfer_release
109+
ctest --test-dir build-tests -R openmeta_transfer_release_gate --output-on-failure
110+
87111
Interop adapter tests
88112
---------------------
89113

src/include/openmeta/metadata_transfer.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,9 @@ struct PrepareTransferRequest final {
429429
bool xmp_include_existing = true;
430430
XmpExistingNamespacePolicy xmp_existing_namespace_policy
431431
= XmpExistingNamespacePolicy::KnownPortableOnly;
432+
XmpExistingStandardNamespacePolicy
433+
xmp_existing_standard_namespace_policy
434+
= XmpExistingStandardNamespacePolicy::PreserveAll;
432435
XmpConflictPolicy xmp_conflict_policy
433436
= XmpConflictPolicy::CurrentBehavior;
434437
bool xmp_exiftool_gpsdatetime_alias = false;

src/include/openmeta/xmp_dump.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ enum class XmpExistingNamespacePolicy : uint8_t {
3838
PreserveCustom,
3939
};
4040

41+
/// Existing standard portable-namespace reconciliation policy.
42+
enum class XmpExistingStandardNamespacePolicy : uint8_t {
43+
/// Preserve existing standard portable namespaces subject to conflict order.
44+
PreserveAll,
45+
/// Drop OpenMeta-managed standard portable properties and regenerate them
46+
/// canonically from EXIF/IPTC mappings when available.
47+
CanonicalizeManaged,
48+
};
49+
4150
/// XMP dump result status.
4251
enum class XmpDumpStatus : uint8_t {
4352
Ok,
@@ -79,6 +88,9 @@ struct XmpPortableOptions final {
7988
/// Existing XMP namespace writeback policy for portable output.
8089
XmpExistingNamespacePolicy existing_namespace_policy
8190
= XmpExistingNamespacePolicy::KnownPortableOnly;
91+
/// Existing standard portable-namespace reconciliation policy.
92+
XmpExistingStandardNamespacePolicy existing_standard_namespace_policy
93+
= XmpExistingStandardNamespacePolicy::PreserveAll;
8294
/// Conflict policy between existing decoded XMP and generated portable
8395
/// EXIF/IPTC mappings.
8496
XmpConflictPolicy conflict_policy = XmpConflictPolicy::CurrentBehavior;
@@ -118,6 +130,8 @@ struct XmpSidecarRequest final {
118130
bool include_existing_xmp = false;
119131
XmpExistingNamespacePolicy portable_existing_namespace_policy
120132
= XmpExistingNamespacePolicy::KnownPortableOnly;
133+
XmpExistingStandardNamespacePolicy portable_existing_standard_namespace_policy
134+
= XmpExistingStandardNamespacePolicy::PreserveAll;
121135
XmpConflictPolicy portable_conflict_policy
122136
= XmpConflictPolicy::CurrentBehavior;
123137
bool portable_exiftool_gpsdatetime_alias = false;

src/openmeta/metadata_transfer.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10000,6 +10000,8 @@ prepare_metadata_for_target_impl(const MetaStore& store,
1000010000
xmp_req.include_existing_xmp = request.xmp_include_existing;
1000110001
xmp_req.portable_existing_namespace_policy
1000210002
= request.xmp_existing_namespace_policy;
10003+
xmp_req.portable_existing_standard_namespace_policy
10004+
= request.xmp_existing_standard_namespace_policy;
1000310005
xmp_req.portable_conflict_policy = request.xmp_conflict_policy;
1000410006
xmp_req.portable_exiftool_gpsdatetime_alias
1000510007
= request.xmp_exiftool_gpsdatetime_alias;
@@ -10373,6 +10375,8 @@ prepare_metadata_for_target_impl(const MetaStore& store,
1037310375
xmp_req.include_existing_xmp = false;
1037410376
xmp_req.portable_existing_namespace_policy
1037510377
= request.xmp_existing_namespace_policy;
10378+
xmp_req.portable_existing_standard_namespace_policy
10379+
= request.xmp_existing_standard_namespace_policy;
1037610380
xmp_req.portable_conflict_policy
1037710381
= request.xmp_conflict_policy;
1037810382

src/openmeta/xmp_dump.cc

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,6 +1370,37 @@ namespace {
13701370
return canonical_portable_property_name(prefix, name);
13711371
}
13721372

1373+
static bool existing_standard_portable_property_is_managed(
1374+
std::string_view prefix, std::string_view name) noexcept
1375+
{
1376+
if (prefix == "tiff" || prefix == "exif") {
1377+
return !name.empty();
1378+
}
1379+
1380+
if (prefix == "dc") {
1381+
return name == "title" || name == "subject"
1382+
|| name == "creator" || name == "rights"
1383+
|| name == "description";
1384+
}
1385+
1386+
if (prefix == "photoshop") {
1387+
return name == "Category"
1388+
|| name == "SupplementalCategories"
1389+
|| name == "Instructions"
1390+
|| name == "AuthorsPosition" || name == "City"
1391+
|| name == "State" || name == "Country"
1392+
|| name == "TransmissionReference"
1393+
|| name == "Headline" || name == "Credit"
1394+
|| name == "Source" || name == "CaptionWriter";
1395+
}
1396+
1397+
if (prefix == "Iptc4xmpCore") {
1398+
return name == "Location" || name == "CountryCode";
1399+
}
1400+
1401+
return false;
1402+
}
1403+
13731404

13741405
static bool parse_indexed_xmp_property_name(std::string_view path,
13751406
std::string_view* out_base,
@@ -3222,7 +3253,8 @@ namespace {
32223253

32233254
static bool process_portable_existing_xmp_entry(
32243255
const ByteArena& arena, std::span<const PortableCustomNsDecl> decls,
3225-
const Entry& e, uint32_t order, SpanWriter* w,
3256+
const XmpPortableOptions& options, const Entry& e, uint32_t order,
3257+
SpanWriter* w,
32263258
PortablePropertyOwnerMap* claims,
32273259
std::vector<PortableIndexedProperty>* indexed) noexcept
32283260
{
@@ -3247,6 +3279,12 @@ namespace {
32473279
|| xmp_property_is_nonportable_blob(prefix, portable_name)) {
32483280
return false;
32493281
}
3282+
if (options.existing_standard_namespace_policy
3283+
== XmpExistingStandardNamespacePolicy::CanonicalizeManaged
3284+
&& existing_standard_portable_property_is_managed(
3285+
prefix, portable_name)) {
3286+
return false;
3287+
}
32503288
bool new_claim = false;
32513289
if (!claim_portable_property_key(claims, prefix, portable_name,
32523290
PortablePropertyOwner::ExistingXmp,
@@ -3270,6 +3308,12 @@ namespace {
32703308
|| xmp_property_is_nonportable_blob(prefix, portable_base)) {
32713309
return false;
32723310
}
3311+
if (options.existing_standard_namespace_policy
3312+
== XmpExistingStandardNamespacePolicy::CanonicalizeManaged
3313+
&& existing_standard_portable_property_is_managed(prefix,
3314+
portable_base)) {
3315+
return false;
3316+
}
32733317

32743318
bool new_claim = false;
32753319
if (!claim_portable_property_key(claims, prefix, portable_base,
@@ -3753,8 +3797,8 @@ namespace {
37533797
continue;
37543798
}
37553799
if (process_portable_existing_xmp_entry(
3756-
arena, custom_decls, e, static_cast<uint32_t>(i), w,
3757-
claims,
3800+
arena, custom_decls, options, e,
3801+
static_cast<uint32_t>(i), w, claims,
37583802
indexed)) {
37593803
*emitted += 1U;
37603804
}
@@ -3960,6 +4004,8 @@ make_xmp_sidecar_options(const XmpSidecarRequest& request) noexcept
39604004
options.portable.include_existing_xmp = request.include_existing_xmp;
39614005
options.portable.existing_namespace_policy
39624006
= request.portable_existing_namespace_policy;
4007+
options.portable.existing_standard_namespace_policy
4008+
= request.portable_existing_standard_namespace_policy;
39634009
options.portable.conflict_policy = request.portable_conflict_policy;
39644010
options.portable.exiftool_gpsdatetime_alias
39654011
= request.portable_exiftool_gpsdatetime_alias;

src/python/openmeta/python/metatransfer.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def probe_exr_attribute_batch(
199199
include_iptc_app13: bool = True,
200200
xmp_include_existing: bool = False,
201201
xmp_existing_namespace_policy: object = openmeta.XmpExistingNamespacePolicy.KnownPortableOnly,
202+
xmp_existing_standard_namespace_policy: object = openmeta.XmpExistingStandardNamespacePolicy.PreserveAll,
202203
xmp_exiftool_gpsdatetime_alias: bool = False,
203204
xmp_project_exif: bool = True,
204205
xmp_project_iptc: bool = True,
@@ -222,6 +223,7 @@ def probe_exr_attribute_batch(
222223
include_iptc_app13=include_iptc_app13,
223224
xmp_include_existing=xmp_include_existing,
224225
xmp_existing_namespace_policy=xmp_existing_namespace_policy,
226+
xmp_existing_standard_namespace_policy=xmp_existing_standard_namespace_policy,
225227
xmp_exiftool_gpsdatetime_alias=xmp_exiftool_gpsdatetime_alias,
226228
xmp_project_exif=xmp_project_exif,
227229
xmp_project_iptc=xmp_project_iptc,
@@ -281,6 +283,7 @@ def update_dng_sdk_file(
281283
include_iptc_app13: bool = True,
282284
xmp_include_existing: bool = False,
283285
xmp_existing_namespace_policy: object = openmeta.XmpExistingNamespacePolicy.KnownPortableOnly,
286+
xmp_existing_standard_namespace_policy: object = openmeta.XmpExistingStandardNamespacePolicy.PreserveAll,
284287
xmp_exiftool_gpsdatetime_alias: bool = False,
285288
xmp_project_exif: bool = True,
286289
xmp_project_iptc: bool = True,
@@ -310,6 +313,7 @@ def update_dng_sdk_file(
310313
include_iptc_app13=include_iptc_app13,
311314
xmp_include_existing=xmp_include_existing,
312315
xmp_existing_namespace_policy=xmp_existing_namespace_policy,
316+
xmp_existing_standard_namespace_policy=xmp_existing_standard_namespace_policy,
313317
xmp_exiftool_gpsdatetime_alias=xmp_exiftool_gpsdatetime_alias,
314318
xmp_project_exif=xmp_project_exif,
315319
xmp_project_iptc=xmp_project_iptc,
@@ -336,6 +340,7 @@ def main(argv: list[str]) -> int:
336340
ap.add_argument("--lossless", action="store_true", help="alias for --format lossless")
337341
ap.add_argument("--xmp-include-existing", action="store_true", help="include existing decoded XMP in generated transfer XMP")
338342
ap.add_argument("--xmp-existing-namespace-policy", choices=["known_portable_only", "preserve_custom"], default="known_portable_only", help="existing XMP namespace writeback policy for generated portable XMP")
343+
ap.add_argument("--xmp-existing-standard-namespace-policy", choices=["preserve_all", "canonicalize_managed"], default="preserve_all", help="reconcile existing managed standard portable XMP properties against canonical EXIF/IPTC mappings")
339344
ap.add_argument("--xmp-include-existing-sidecar", action="store_true", help="include an existing sibling .xmp sidecar from the output/edit target path in generated transfer XMP")
340345
ap.add_argument("--xmp-existing-sidecar-precedence", choices=["sidecar_wins", "source_wins"], default="sidecar_wins", help="conflict precedence between an existing output-side .xmp and source-embedded existing XMP")
341346
ap.add_argument("--xmp-include-existing-destination-embedded", action="store_true", help="include existing embedded XMP from the edit target in generated transfer XMP")
@@ -929,6 +934,13 @@ def main(argv: list[str]) -> int:
929934
xmp_existing_namespace_policy = (
930935
openmeta.XmpExistingNamespacePolicy.PreserveCustom
931936
)
937+
xmp_existing_standard_namespace_policy = (
938+
openmeta.XmpExistingStandardNamespacePolicy.PreserveAll
939+
)
940+
if args.xmp_existing_standard_namespace_policy == "canonicalize_managed":
941+
xmp_existing_standard_namespace_policy = (
942+
openmeta.XmpExistingStandardNamespacePolicy.CanonicalizeManaged
943+
)
932944

933945
xmp_writeback_mode = openmeta.XmpWritebackMode.EmbeddedOnly
934946
if args.xmp_writeback == "sidecar":
@@ -1023,6 +1035,7 @@ def main(argv: list[str]) -> int:
10231035
xmp_project_iptc=not args.xmp_no_iptc_projection,
10241036
xmp_include_existing=bool(args.xmp_include_existing),
10251037
xmp_existing_namespace_policy=xmp_existing_namespace_policy,
1038+
xmp_existing_standard_namespace_policy=xmp_existing_standard_namespace_policy,
10261039
xmp_conflict_policy=xmp_conflict_policy,
10271040
xmp_exiftool_gpsdatetime_alias=bool(args.xmp_exiftool_gpsdatetime_alias),
10281041
makernote_policy=makernote_policy,

0 commit comments

Comments
 (0)