Skip to content

Preserve source metadata in extracted image manifests - #3861

Merged
MrNeRF merged 4 commits into
MrNeRF:devfrom
francescofugazzi:feat/media-source-metadata-manifest
Oct 28, 2026
Merged

MrNeRF merged 4 commits into
MrNeRF:devfrom
francescofugazzi:feat/media-source-metadata-manifest

Conversation

@francescofugazzi

@francescofugazzi francescofugazzi commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Image extraction can now keep the input video's static metadata alongside the exported frames. Enabling source preservation writes one catalog and summary to extraction_metadata.json, while each frame keeps its existing source timestamp and extraction information. Python callers can also read an image's source metadata and interpreted properties through the same catalog and JSON representation used by the CLI.

APIs and file output

  • Add FileExtraction.preserve_source_metadata to C++ and Python, and --preserve-source-metadata to media-ingest extract. The option is disabled by default and requests the extraction manifest when enabled.
  • Collect the video observation from the FFmpeg context already opened for extraction, before delivering frames. Reuse the existing collector, immutable catalog, summary and serializers.
  • Store a single source_metadata object containing the versioned catalog and static stream summary. source_metadata_policy identifies preserve_static_container_v1. Container tags, observed stream identities and coded side data retain their collected bytes as hex, with explicit collection/index/summary limits.
  • Preserve the source path's native bytes and encoding through the existing path conversion helper. Input declarations remain separate from output dimensions, rotation, resampling and color processing.
  • Use one shared preservation policy constant for checkpoint identity and the manifest. Switching it in either direction rejects a resumed request before adopting or replacing recorded images. Verified resumed runs regenerate the derived manifest.
  • Publish an empty frames array and the requested source observation when every candidate frame is rejected. Existing behavior with source preservation disabled stays unchanged.
  • Add MediaIngest.source_metadata_json(path) for Python image metadata access. It returns the schema_version/success/metadata/summary envelope, reusing MediaIngest::sourceMetadata and summarizeImageMetadata. A shared json_detail::sourceMetadataResult helper constructs this envelope for both metadata CLI commands, the Python binding and the extraction manifest. Collection, interpretation and serialization release the GIL; the returned JSON owns its data.

CLI example:

media-ingest extract input.mp4 --output frames --interval 10 --preserve-source-metadata

Python examples:

import json
from pathlib import Path
import lichtfeld

media = lichtfeld.media
source = json.loads(media.MediaIngest.source_metadata_json(Path("photo.jpg")))

request = media.IngestRequest()
request.input = Path("input.mp4")
files = media.FileExtraction()
files.files.output_directory = Path("frames")
files.preserve_source_metadata = True
report = media.MediaIngest.extract_files(request, files)

The retained observation can include GPS, identifiers and private information in tags or opaque payloads. The opt-in applies to the entire observation. Turning it off affects newly generated manifests; existing artifacts remain on disk. Atomic publication applies to the manifest itself, while image files and the manifest keep the existing recovery semantics.

Shared probe hardening and test execution

The stream descriptor previously copied the entire rotate tag independently of the raw metadata collector's byte budget. Limit that presentation copy to 4096 bytes and expose rotate_tag_limited when larger text is omitted. The summary propagates the limited state. Numeric interpretation still borrows the demuxer value, preserving valid angles, including values with extensive whitespace, and the existing tag/display-matrix precedence. The raw collector retains its separate budget and exact collected bytes. Ordinary orientation JSON is unchanged; the omission marker is emitted when needed.

The media Python tests now start with PYTHONUTF8=1, matching the Windows workflow. The CLI test reads the UTF-8 manifest explicitly and verifies its Unicode source locator. Python stubs were regenerated through the CMake targets.

Validation

Local validation of the current media revision:

  • Windows: focused FAST_COMPILE/sccache media build using existing qualified dependencies; 54 Python binding cases passed with two declared skips, four native contract entry points passed, and 64 metadata CLI cases passed with two declared skips.
  • Linux: 54 Python binding cases passed with one declared skip; native contracts, eight CTest groups and 64 metadata CLI cases passed.
  • macOS arm64: 54 Python binding cases passed with two declared skips; native contracts, eight CTest groups and 64 metadata CLI cases passed, with two declared CLI skips.
  • Linux ASan/UBSan with leak detection: native contracts and 64 metadata CLI cases passed. Additional real CLI extractions used a 9 MiB rotation tag, both exporting four frames and rejecting all frames, with no sanitizer diagnostics.
  • CMake/nanobind regenerated the media stub from the production register_media implementation and project pattern file; its API content matches the committed stub. clang-format 18, locale completeness, I/O discipline, backend neutrality and the error-debt baseline checks passed.

The binding cases cover CLI parity, partial collection, owned results, concurrent calls, native paths and supported image carriers. Parser-specific XMP contracts and the 4095/4096/4097-byte rotation boundaries remain in their existing native tests. The full extraction cases cover PNG/JPEG/EXR/TIFF16/JXL/AVIF output, opt-in/default behavior, unchanged PNG output bytes, additional stream tags, invalid UTF-8 tag bytes, output/source geometry separation, resume policy changes, empty selections, previous manifest replacement, cancellation and manifest path collisions. The 9 MiB rotation case remains an end-to-end Python extraction test.

The declared Python skips concern the existing reserved-publication qualification and native non-UTF-8 filename support. A fresh full application rebuild, full hardware backend matrix, TSan and process-wide allocation-failure qualification were not completed for the serializer/test refactoring. The retained metadata scope is static container and stream observations.

@francescofugazzi francescofugazzi added the dev branch Applies to the dev branch only label Oct 27, 2026

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Built 668bed1 and ran it. All 27 Media ctest contracts pass. I also ran the CLI by hand on a small mkv with a comment and a location tag:

  • Flag off: the manifest and PNGs are identical to dev apart from timings, and the tag shows up nowhere.
  • Flag on: the tag appears only in source_metadata, and the PNGs don't change.
  • All frames rejected: writes frames: [] only when preserving.
  • Resume: flipping the flag on a resumed run fails with "Checkpoint identity or frame changed" and leaves the images alone.

So the behaviour is right. What I'd like changed is the duplication.

  1. The result envelope {"schema_version", 1}, {"success", true}, {"metadata", ...}, {"summary", ...} is now built in four places. cli.cpp:103 and :113 already had it, and the PR adds source_metadata_json in py_media.cpp and video_frame_extractor.cpp:1524. The point of the Python API is "same JSON as the CLI", so could you add one json_detail helper that takes the catalog and summary, and use it in all four places? The same goes for "preserve_static_container_v1": it's written separately into the checkpoint identity (resumable_file_sink.cpp:175) and the manifest (video_frame_extractor.cpp:2585). One constant keeps the two from drifting apart, and drift there would break resume.

  2. test_media_bindings.py grows by 368 lines, and several cases retest things that are covered elsewhere:

    • test_named_xmp_and_partial_rejection_keep_original_packets repeats the native XMP contracts in metadata_observation_contracts.cpp.
    • test_default_budget_reports_partial_collection could be one more entry in the CLI parity loop.
    • test_errors_and_recovery_use_the_existing_contract mostly checks nanobind's path conversion.
    • The 4097-byte "spaced" rotation case duplicates probe_unit_contracts.cpp. The 9 MiB case through the full pipeline is the useful one.

    Could you trim those?

Smaller things:

  • test_source_metadata_keeps_other_streams_without_decoding_them only checks that the audio tag is kept. It doesn't check anything about decoding, so a name like ..._keeps_other_stream_tags would fit better.
  • The # -*- coding: utf-8 -*- lines added to test_media_bindings.py and test_ingest_cli.py do nothing on Python 3.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Addressed in b66f820.

  • Added json_detail::sourceMetadataResult(catalog, summary) and used it in both metadata CLI commands, source_metadata_json and the extraction manifest. The schema and each caller's existing formatting/UTF-8 handling stay unchanged. Checkpoint identity and the manifest now use the same internal preservation-policy constant.
  • Removed the Python XMP parser-contract duplication and the generic nanobind path-conversion checks. The limited-collection fixture is now another case in the CLI/Python parity loop. Removed the 4097-byte rotation variant from Python; native boundary tests remain, together with the full 9 MiB extraction case in Python. Ownership, concurrency, formats, resume and manifest publication checks remain covered.
  • Renamed the audio case to test_source_metadata_keeps_other_stream_tags. Removed the redundant coding cookies, retaining PYTHONUTF8=1 for test startup and explicit UTF-8 JSON reads.

Validation: 54 Python binding cases and 64 metadata CLI cases passed on Windows, Linux and macOS arm64, with the declared skips. Native contracts passed on all three. Linux ASan/UBSan with leak detection passed the native/CLI checks and the two real 9 MiB extractions. CMake regenerated the media stub with no API changes; clang-format 18, locale, I/O, backend-neutrality and error-debt checks passed. The PR description now reflects the current test coverage.

The earlier macOS Actions failure was the unused cuda_runtime.h include in tests/test_collision_jobs.cpp, already present in dev. Its removal is already included in #3844; this followup keeps the media review changes separate from that fix.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all four points are done. sourceMetadataResult now serves the CLI, Python and the manifest, the policy string is one constant, the four duplicate binding tests are gone, and the oversized-rotation case runs only through the full extraction.

I rebuilt b66f820 and reran my checks. All 27 Media ctest contracts pass (CLI and Python binding contracts included). With the flag off, the manifest and PNGs are identical to dev, and the tag shows up nowhere. With it on, the tag appears only in source_metadata. All frames rejected still writes frames: [] only when preserving. Flipping the flag on resume still fails with rc 4 and leaves the images alone.

The macOS failure is dev's #include <cuda_runtime.h> in tests/test_collision_jobs.cpp:18, not this PR.

@MrNeRF
MrNeRF merged commit 02ada60 into MrNeRF:dev Oct 28, 2026
9 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dev branch Applies to the dev branch only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants