Skip to content

Commit 0ffcf8d

Browse files
committed
Add EmbeddedAndSidecar XMP writeback mode
Added new XmpWritebackMode::EmbeddedAndSidecar to support writing generated XMP both embedded and as a sibling .xmp sidecar. Update public docs and README to document the new option. Adjust core logic so sidecar output is requested whenever the mode is not EmbeddedOnly and only strips embedded XMP blocks in SidecarOnly mode; in dual mode the embedded carrier is preserved and a sidecar is emitted. Expose the new enum value to the Python binding and Python CLI, extend argument validation to require --output for non-embedded modes, and update the metatransfer tool help/parser/messages. Add a unit test verifying embedded+sidecar behavior.
1 parent a0e373f commit 0ffcf8d

File tree

8 files changed

+107
-21
lines changed

8 files changed

+107
-21
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ In practice:
101101
source-embedded existing XMP.
102102
- File-helper execution, `metatransfer`, and the Python transfer wrapper now
103103
share a bounded XMP carrier choice:
104-
embedded XMP only, or sidecar-only writeback to a sibling `.xmp` when a
105-
generated XMP packet exists for the prepared transfer.
104+
embedded XMP only, sidecar-only writeback to a sibling `.xmp`, or dual
105+
embedded-plus-sidecar writeback when a generated XMP packet exists for the
106+
prepared transfer.
106107
- Prepared bundles record resolved policy decisions for MakerNote, JUMBF,
107108
C2PA, EXIF-to-XMP projection, and IPTC-to-XMP projection.
108109
- This is still not a full MWG-style sync engine. OpenMeta does not yet try to

docs/metadata_transfer_plan.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,13 +235,14 @@ Current controls:
235235
- `xmp_writeback_mode` on the file-helper execution path:
236236
- `EmbeddedOnly`
237237
- `SidecarOnly`
238+
- `EmbeddedAndSidecar`
238239
- CLI:
239240
- `--xmp-include-existing-sidecar`
240241
- `--xmp-existing-sidecar-precedence <sidecar_wins|source_wins>`
241242
- `--xmp-no-exif-projection`
242243
- `--xmp-no-iptc-projection`
243244
- `--xmp-conflict-policy <current|existing_wins|generated_wins>`
244-
- `--xmp-writeback <embedded|sidecar>`
245+
- `--xmp-writeback <embedded|sidecar|embedded_and_sidecar>`
245246

246247
Current behavior:
247248
- existing XMP can still be included independently
@@ -259,14 +260,16 @@ Current behavior:
259260
fallback carrier when IPTC projection is enabled
260261
- file-helper export can now strip prepared embedded XMP blocks and return
261262
canonical sidecar output guidance instead
263+
- file-helper export can also keep generated embedded XMP while emitting the
264+
same generated packet as a sibling `.xmp` sidecar
262265
- the public `metatransfer` CLI and Python transfer wrapper can now persist
263-
that generated XMP as a sibling `.xmp` sidecar when sidecar writeback is
264-
selected
266+
that generated XMP as a sibling `.xmp` sidecar when sidecar or dual-write
267+
XMP writeback is selected
265268

266269
This is deliberately narrower than a full sync engine. It does not yet define:
267270
- full EXIF vs XMP precedence rules
268271
- MWG-style reconciliation
269-
- canonical sidecar vs embedded writeback policy
272+
- canonical destination embedded-vs-sidecar reconciliation policy
270273
- namespace-wide deduplication and normalization rules beyond the current
271274
generated-XMP path
272275

src/include/openmeta/metadata_transfer.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,7 @@ struct ExecutePreparedTransferResult final {
11651165
enum class XmpWritebackMode : uint8_t {
11661166
EmbeddedOnly,
11671167
SidecarOnly,
1168+
EmbeddedAndSidecar,
11681169
};
11691170

11701171
/// Options for \ref execute_prepared_transfer_file.

src/openmeta/metadata_transfer.cc

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21413,7 +21413,7 @@ execute_prepared_transfer_file(
2141321413
{
2141421414
ExecutePreparedTransferFileResult out;
2141521415
out.xmp_sidecar_requested
21416-
= options.xmp_writeback_mode == XmpWritebackMode::SidecarOnly;
21416+
= options.xmp_writeback_mode != XmpWritebackMode::EmbeddedOnly;
2141721417
const bool c2pa_stage_requested = options.c2pa_stage_requested
2141821418
|| options.c2pa_signed_package_provided;
2141921419
out.execute.c2pa_stage_requested = c2pa_stage_requested;
@@ -21437,12 +21437,20 @@ execute_prepared_transfer_file(
2143721437
if (!out.prepared.bundle.generated_xmp_sidecar.empty()) {
2143821438
out.xmp_sidecar_output = out.prepared.bundle.generated_xmp_sidecar;
2143921439
out.xmp_sidecar_status = TransferStatus::Ok;
21440-
const uint32_t removed_xmp = remove_prepared_blocks_by_kind(
21441-
&out.prepared.bundle, TransferBlockKind::Xmp);
21442-
if (removed_xmp == 0U) {
21440+
if (options.xmp_writeback_mode
21441+
== XmpWritebackMode::SidecarOnly) {
21442+
const uint32_t removed_xmp
21443+
= remove_prepared_blocks_by_kind(
21444+
&out.prepared.bundle, TransferBlockKind::Xmp);
21445+
if (removed_xmp == 0U) {
21446+
out.xmp_sidecar_message
21447+
= "prepared xmp sidecar bytes available without "
21448+
"embedded xmp carrier blocks";
21449+
}
21450+
} else {
2144321451
out.xmp_sidecar_message
21444-
= "prepared xmp sidecar bytes available without "
21445-
"embedded xmp carrier blocks";
21452+
= "prepared xmp sidecar bytes will be written "
21453+
"alongside embedded xmp carriers";
2144621454
}
2144721455
} else {
2144821456
out.xmp_sidecar_status = TransferStatus::Ok;

src/python/openmeta/python/metatransfer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def main(argv: list[str]) -> int:
199199
ap.add_argument("--xmp-no-exif-projection", action="store_true", help="do not mirror EXIF-derived properties into generated XMP")
200200
ap.add_argument("--xmp-no-iptc-projection", action="store_true", help="do not mirror IPTC-derived properties into generated XMP")
201201
ap.add_argument("--xmp-conflict-policy", choices=["current", "existing_wins", "generated_wins"], default="current", help="conflict policy between existing decoded XMP and generated portable EXIF/IPTC XMP")
202-
ap.add_argument("--xmp-writeback", choices=["embedded", "sidecar"], default="embedded", help="keep generated XMP embedded, or persist it as a sibling .xmp sidecar when --output is used")
202+
ap.add_argument("--xmp-writeback", choices=["embedded", "sidecar", "embedded_and_sidecar"], default="embedded", help="keep generated XMP embedded, persist it only as a sibling .xmp sidecar, or do both when --output is used")
203203
ap.add_argument("--xmp-exiftool-gpsdatetime-alias", action="store_true", help="emit exif:GPSDateTime alias for GPS time in portable mode")
204204
ap.add_argument("--no-exif", action="store_true", help="skip EXIF APP1 preparation")
205205
ap.add_argument("--no-xmp", action="store_true", help="skip XMP APP1 preparation")
@@ -432,8 +432,8 @@ def main(argv: list[str]) -> int:
432432
"--target-png, --target-jp2, --target-jxl, --target-heif, "
433433
"--target-avif, or --target-cr3"
434434
)
435-
if args.xmp_writeback == "sidecar" and not args.output:
436-
ap.error("--xmp-writeback sidecar requires --output")
435+
if args.xmp_writeback != "embedded" and not args.output:
436+
ap.error("--xmp-writeback sidecar or embedded_and_sidecar requires --output")
437437
if args.dump_c2pa_binding and (
438438
args.target_tiff
439439
or args.target_webp
@@ -727,6 +727,8 @@ def main(argv: list[str]) -> int:
727727
xmp_writeback_mode = openmeta.XmpWritebackMode.EmbeddedOnly
728728
if args.xmp_writeback == "sidecar":
729729
xmp_writeback_mode = openmeta.XmpWritebackMode.SidecarOnly
730+
elif args.xmp_writeback == "embedded_and_sidecar":
731+
xmp_writeback_mode = openmeta.XmpWritebackMode.EmbeddedAndSidecar
730732
xmp_existing_sidecar_mode = openmeta.XmpExistingSidecarMode.Ignore
731733
if args.xmp_include_existing_sidecar:
732734
xmp_existing_sidecar_mode = (

src/python/src/openmeta_module.cc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3342,7 +3342,9 @@ NB_MODULE(_openmeta, m)
33423342

33433343
nb::enum_<XmpWritebackMode>(m, "XmpWritebackMode")
33443344
.value("EmbeddedOnly", XmpWritebackMode::EmbeddedOnly)
3345-
.value("SidecarOnly", XmpWritebackMode::SidecarOnly);
3345+
.value("SidecarOnly", XmpWritebackMode::SidecarOnly)
3346+
.value("EmbeddedAndSidecar",
3347+
XmpWritebackMode::EmbeddedAndSidecar);
33463348

33473349
nb::enum_<XmpExistingSidecarMode>(m, "XmpExistingSidecarMode")
33483350
.value("Ignore", XmpExistingSidecarMode::Ignore)

src/tools/metatransfer.cc

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ namespace {
5353
" --xmp-conflict-policy <current|existing_wins|generated_wins>\n"
5454
" Conflict policy between existing decoded XMP and\n"
5555
" generated portable EXIF/IPTC XMP properties\n"
56-
" --xmp-writeback <embedded|sidecar>\n"
56+
" --xmp-writeback <embedded|sidecar|embedded_and_sidecar>\n"
5757
" Keep generated XMP embedded, or strip embedded XMP\n"
58-
" carriers and persist a sibling .xmp sidecar when --output is used\n"
58+
" carriers and persist a sibling .xmp sidecar when --output is used,\n"
59+
" or keep both embedded and sidecar XMP carriers\n"
5960
" --xmp-exiftool-gpsdatetime-alias\n"
6061
" Emit exif:GPSDateTime alias in portable mode\n"
6162
" --no-exif Skip EXIF APP1 preparation\n"
@@ -378,6 +379,10 @@ namespace {
378379
*out = XmpWritebackMode::SidecarOnly;
379380
return true;
380381
}
382+
if (std::strcmp(s, "embedded_and_sidecar") == 0) {
383+
*out = XmpWritebackMode::EmbeddedAndSidecar;
384+
return true;
385+
}
381386
return false;
382387
}
383388

@@ -1366,7 +1371,7 @@ main(int argc, char** argv)
13661371
&xmp_writeback_mode)) {
13671372
std::fprintf(stderr,
13681373
"invalid --xmp-writeback value "
1369-
"(expected embedded|sidecar)\n");
1374+
"(expected embedded|sidecar|embedded_and_sidecar)\n");
13701375
return 2;
13711376
}
13721377
i += 1;
@@ -2154,10 +2159,11 @@ main(int argc, char** argv)
21542159
std::fprintf(stderr, "--output is not supported for --target-exr\n");
21552160
return 2;
21562161
}
2157-
if (xmp_writeback_mode == XmpWritebackMode::SidecarOnly
2162+
if (xmp_writeback_mode != XmpWritebackMode::EmbeddedOnly
21582163
&& output_path.empty()) {
21592164
std::fprintf(stderr,
2160-
"--xmp-writeback sidecar requires --output\n");
2165+
"--xmp-writeback sidecar or embedded_and_sidecar "
2166+
"requires --output\n");
21612167
return 2;
21622168
}
21632169
const bool c2pa_wrapper_target_ok

tests/metadata_transfer_api_test.cc

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7239,6 +7239,69 @@ TEST(MetadataTransferApi,
72397239
"OpenMeta Transfer Source"));
72407240
}
72417241

7242+
TEST(MetadataTransferApi,
7243+
ExecutePreparedTransferFileJpegEmbeddedAndSidecarKeepsEmbeddedXmp)
7244+
{
7245+
std::vector<std::byte> source_jpeg;
7246+
ASSERT_TRUE(build_test_transfer_source_jpeg_bytes(&source_jpeg));
7247+
const std::string source_path = unique_temp_path(".jpg");
7248+
ASSERT_TRUE(write_bytes_file(
7249+
source_path,
7250+
std::span<const std::byte>(source_jpeg.data(), source_jpeg.size())));
7251+
7252+
const std::vector<std::byte> target_jpeg = make_jpeg_with_segments({});
7253+
const std::string target_path = unique_temp_path(".jpg");
7254+
ASSERT_TRUE(write_bytes_file(
7255+
target_path,
7256+
std::span<const std::byte>(target_jpeg.data(), target_jpeg.size())));
7257+
7258+
openmeta::ExecutePreparedTransferFileOptions options;
7259+
options.prepare.prepare.target_format = openmeta::TransferTargetFormat::Jpeg;
7260+
options.prepare.prepare.include_icc_app2 = false;
7261+
options.prepare.prepare.include_iptc_app13 = false;
7262+
options.edit_target_path = target_path;
7263+
options.execute.edit_apply = true;
7264+
options.xmp_writeback_mode
7265+
= openmeta::XmpWritebackMode::EmbeddedAndSidecar;
7266+
7267+
const openmeta::ExecutePreparedTransferFileResult result
7268+
= openmeta::execute_prepared_transfer_file(source_path.c_str(), options);
7269+
std::remove(source_path.c_str());
7270+
std::remove(target_path.c_str());
7271+
7272+
ASSERT_EQ(result.prepared.file_status, openmeta::TransferFileStatus::Ok);
7273+
ASSERT_EQ(result.prepared.prepare.status, openmeta::TransferStatus::Ok);
7274+
ASSERT_EQ(result.execute.edit_apply.status, openmeta::TransferStatus::Ok);
7275+
EXPECT_TRUE(result.xmp_sidecar_requested);
7276+
EXPECT_EQ(result.xmp_sidecar_status, openmeta::TransferStatus::Ok);
7277+
EXPECT_FALSE(result.xmp_sidecar_output.empty());
7278+
EXPECT_EQ(count_blocks_with_route(result.prepared.bundle, "jpeg:app1-xmp"),
7279+
1U);
7280+
7281+
openmeta::MetaStore edited_store;
7282+
ASSERT_TRUE(decode_transfer_roundtrip_store(
7283+
std::span<const std::byte>(result.execute.edited_output.data(),
7284+
result.execute.edited_output.size()),
7285+
&edited_store));
7286+
EXPECT_TRUE(store_has_text_entry(edited_store,
7287+
exif_key_view("exififd", 0x9003U),
7288+
"2024:01:02 03:04:05"));
7289+
EXPECT_TRUE(store_has_text_entry(
7290+
edited_store,
7291+
xmp_key_view("http://ns.adobe.com/xap/1.0/", "CreatorTool"),
7292+
"OpenMeta Transfer Source"));
7293+
7294+
openmeta::MetaStore sidecar_store;
7295+
ASSERT_TRUE(decode_transfer_roundtrip_store(
7296+
std::span<const std::byte>(result.xmp_sidecar_output.data(),
7297+
result.xmp_sidecar_output.size()),
7298+
&sidecar_store));
7299+
EXPECT_TRUE(store_has_text_entry(
7300+
sidecar_store,
7301+
xmp_key_view("http://ns.adobe.com/xap/1.0/", "CreatorTool"),
7302+
"OpenMeta Transfer Source"));
7303+
}
7304+
72427305
TEST(MetadataTransferApi,
72437306
ExecutePreparedTransferFileSidecarOnlyRequiresEditTargetPath)
72447307
{

0 commit comments

Comments
 (0)