From c8d1f10cd0d869979fa8578615170d0d23482888 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Mon, 12 Oct 2026 23:01:08 +0000 Subject: [PATCH] feat(plugins): add Honor & Huawei Live Photo metadata provider (honor_live_photo) --- lib/core/CMakeLists.txt | 2 + .../motion_photo/jpeg_container_splitter.cc | 6 +- .../metadata_provider_registry.cc | 2 + .../honor_live_photo_metadata.proto | 49 +++ .../honor_live_photo_provider.cc | 309 ++++++++++++++++++ .../honor_live_photo_provider.h | 55 ++++ .../honor_live_photo_provider_test.cc | 282 ++++++++++++++++ 7 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 lib/core/plugins/honor_live_photo/honor_live_photo_metadata.proto create mode 100644 lib/core/plugins/honor_live_photo/honor_live_photo_provider.cc create mode 100644 lib/core/plugins/honor_live_photo/honor_live_photo_provider.h create mode 100644 lib/core/plugins/honor_live_photo/honor_live_photo_provider_test.cc diff --git a/lib/core/CMakeLists.txt b/lib/core/CMakeLists.txt index f676d8d..0d1c8fa 100644 --- a/lib/core/CMakeLists.txt +++ b/lib/core/CMakeLists.txt @@ -78,6 +78,7 @@ protobuf_generate( ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../../samples/android/external/install_arm64-v8a/include PROTOS + plugins/honor_live_photo/honor_live_photo_metadata.proto plugins/legacy_micro_video/legacy_micro_video_metadata.proto motion_photo/proto/metadata_collection.proto motion_photo/proto/motion_photo_metadata.proto @@ -203,6 +204,7 @@ if (NOT ANDROID) # Tests add_executable(motion_photo_tests motion_photo/tests/test_main.cc + plugins/honor_live_photo/honor_live_photo_provider_test.cc plugins/legacy_micro_video/legacy_micro_video_provider_test.cc motion_photo/tests/motion_photo_builder_test.cc motion_photo/tests/motion_photo_checker_test.cc diff --git a/lib/core/motion_photo/src/motion_photo/jpeg_container_splitter.cc b/lib/core/motion_photo/src/motion_photo/jpeg_container_splitter.cc index 22c76df..c80f3eb 100644 --- a/lib/core/motion_photo/src/motion_photo/jpeg_container_splitter.cc +++ b/lib/core/motion_photo/src/motion_photo/jpeg_container_splitter.cc @@ -103,11 +103,7 @@ class JpegContainerSplitter : public ContainerSplitter, block.format_identifier = "container.trailer"; block.offset = trailer_start; block.total_payload_size = trailer_length; - // Copy only header bytes needed for format verification (e.g. ftyp, moov) - // to avoid allocating/copying large video payloads (up to 120 MB) into - // RAM. - size_t bytes_to_copy = std::min(trailer_length, 64); - if (CopyFromDataSource(data_source, trailer_start, bytes_to_copy, + if (CopyFromDataSource(data_source, trailer_start, trailer_length, &block.bytes)) { blocks_.push_back(std::move(block)); } diff --git a/lib/core/motion_photo/src/motion_photo/metadata_provider_registry.cc b/lib/core/motion_photo/src/motion_photo/metadata_provider_registry.cc index 7526b89..8a2b84b 100644 --- a/lib/core/motion_photo/src/motion_photo/metadata_provider_registry.cc +++ b/lib/core/motion_photo/src/motion_photo/metadata_provider_registry.cc @@ -20,6 +20,7 @@ #include #include "motion_photo/metadata_provider.h" +#include "plugins/honor_live_photo/honor_live_photo_provider.h" #include "plugins/legacy_micro_video/legacy_micro_video_provider.h" namespace libmotionphoto { @@ -46,6 +47,7 @@ std::vector>& MetadataProviderRegistry::Registry() { static auto* registry = new std::vector>{ + std::make_shared(), std::make_shared(), }; return *registry; diff --git a/lib/core/plugins/honor_live_photo/honor_live_photo_metadata.proto b/lib/core/plugins/honor_live_photo/honor_live_photo_metadata.proto new file mode 100644 index 0000000..2944c06 --- /dev/null +++ b/lib/core/plugins/honor_live_photo/honor_live_photo_metadata.proto @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package libmotionphoto.motion_photo; + +option java_multiple_files = true; +option java_package = "com.google.libmotionphoto.motionphoto.proto"; +option optimize_for = LITE_RUNTIME; + +// Metadata for Honor / Huawei Moving Picture ("HiHonor_*", "LivePhoto_*", +// and "LIVE_" trailer formats). +message HonorLivePhotoMetadata { + // Trailer format variant ("hihonor_tlv", "livephoto_tlv", or "live_footer"). + string format_variant = 1; + + // True if an embedded MP4 video stream was found in the trailer. + bool has_embedded_video = 2; + + // Byte offset of the embedded MP4 stream relative to the start of the + // trailer block. + uint64 video_offset = 3; + + // Byte length of the embedded MP4 stream. + uint64 video_length = 4; + + // Camera sceneMode parsed from HiHonor_OfflineData JSON (e.g., 73 for + // Moving Photo mode), or 0 if absent. + int32 scene_mode = 5; + + // Presentation / cover frame timestamp in microseconds, if present. + int64 primary_image_timestamp_us = 6; + + // Backward-linked 20-byte TLV tags discovered in the trailer (ordered from + // EOF backward). + repeated string tlv_tags = 7; +} diff --git a/lib/core/plugins/honor_live_photo/honor_live_photo_provider.cc b/lib/core/plugins/honor_live_photo/honor_live_photo_provider.cc new file mode 100644 index 0000000..1143bda --- /dev/null +++ b/lib/core/plugins/honor_live_photo/honor_live_photo_provider.cc @@ -0,0 +1,309 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "plugins/honor_live_photo/honor_live_photo_provider.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "honor_live_photo_metadata.pb.h" // NOLINT +#include "motion_photo/metadata_block.h" + +namespace libmotionphoto { +namespace motion_photo { + +namespace { + +constexpr size_t kTagWidth = 20; +constexpr size_t kTlvFooterSize = 24; // 4-byte u32LE length + 20-byte tag +constexpr size_t kMaxTlvRecords = 128; + +uint32_t ReadLe32(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); +} + +absl::string_view TrimPadding(absl::string_view sv) { + while (!sv.empty() && + (sv.back() == '\0' || sv.back() == ' ' || sv.back() == '\r' || + sv.back() == '\n' || sv.back() == '\t')) { + sv.remove_suffix(1); + } + while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t')) { + sv.remove_prefix(1); + } + return sv; +} + +bool HasIsoBmffAt(const std::vector& bytes, size_t offset, + size_t length) { + if (length < 8 || offset + length > bytes.size()) { + return false; + } + absl::string_view tag(reinterpret_cast(bytes.data() + offset + 4), + 4); + return tag == "ftyp" || tag == "moov" || tag == "mdat" || tag == "free" || + tag == "skip" || tag == "wide"; +} + +bool ExtractJsonInt32(absl::string_view json, absl::string_view key, + int32_t* out_val) { + std::string needle = "\"" + std::string(key) + "\""; + size_t pos = json.find(needle); + if (pos == absl::string_view::npos) { + return false; + } + size_t colon = json.find(':', pos + needle.size()); + if (colon == absl::string_view::npos) { + return false; + } + size_t num_start = colon + 1; + while (num_start < json.size() && + (json[num_start] == ' ' || json[num_start] == '\t')) { + ++num_start; + } + size_t num_end = num_start; + if (num_end < json.size() && json[num_end] == '-') { + ++num_end; + } + while (num_end < json.size() && json[num_end] >= '0' && + json[num_end] <= '9') { + ++num_end; + } + if (num_end == num_start) { + return false; + } + return absl::SimpleAtoi(json.substr(num_start, num_end - num_start), out_val); +} + +bool ParseHonorTrailer(const RawMetadataBlock& block, + HonorLivePhotoMetadata* out_meta, + std::string* error_message) { + const auto& bytes = block.bytes; + if (bytes.size() < kTagWidth) { + if (error_message) { + *error_message = "Honor/Huawei trailer is shorter than 20 bytes"; + } + return false; + } + + out_meta->set_primary_image_timestamp_us(-1); + + absl::string_view tail_20 = TrimPadding(absl::string_view( + reinterpret_cast(bytes.data() + bytes.size() - kTagWidth), + kTagWidth)); + + // Case 1: Classic Huawei/Honor "LIVE_" 20-byte / 40-byte ASCII footer. + if (absl::StartsWith(tail_20, "LIVE_")) { + out_meta->set_format_variant("live_footer"); + absl::string_view size_str = tail_20.substr(5); + uint64_t mp4_size = 0; + if (!absl::SimpleAtoi(size_str, &mp4_size) || mp4_size == 0 || + mp4_size + kTagWidth > bytes.size()) { + if (error_message) { + *error_message = "Invalid MP4 size in LIVE_ footer"; + } + return false; + } + + // Check if there is a 20-byte cover offset immediately before "LIVE_" + size_t candidate_offset = bytes.size() - kTagWidth - mp4_size; + if (bytes.size() >= 2 * kTagWidth + mp4_size) { + size_t alt_offset = bytes.size() - 2 * kTagWidth - mp4_size; + if (HasIsoBmffAt(bytes, alt_offset, mp4_size)) { + candidate_offset = alt_offset; + absl::string_view cover_str = TrimPadding(absl::string_view( + reinterpret_cast(bytes.data() + bytes.size() - + 2 * kTagWidth), + kTagWidth)); + int64_t cover_ms = -1; + if (absl::SimpleAtoi(cover_str, &cover_ms) && cover_ms >= 0) { + out_meta->set_primary_image_timestamp_us(cover_ms * 1000); + } + } + } + + if (!HasIsoBmffAt(bytes, candidate_offset, mp4_size)) { + if (error_message) { + *error_message = "LIVE_ footer does not point to a valid MP4 stream"; + } + return false; + } + + out_meta->set_has_embedded_video(true); + out_meta->set_video_offset(candidate_offset); + out_meta->set_video_length(mp4_size); + return true; + } + + // Case 2: Backward-linked 20-byte TLV chain ("HiHonor_*" or "LivePhoto_*"). + if (absl::StartsWith(tail_20, "HiHonor_") || + absl::StartsWith(tail_20, "LivePhoto_")) { + if (bytes.size() < kTlvFooterSize) { + if (error_message) { + *error_message = "TLV trailer is shorter than 24 bytes"; + } + return false; + } + + out_meta->set_format_variant(absl::StartsWith(tail_20, "HiHonor_") + ? "hihonor_tlv" + : "livephoto_tlv"); + + size_t pos = bytes.size(); + size_t record_count = 0; + while (pos >= kTlvFooterSize && record_count < kMaxTlvRecords) { + absl::string_view tag = TrimPadding(absl::string_view( + reinterpret_cast(bytes.data() + pos - kTagWidth), + kTagWidth)); + if (!absl::StartsWith(tag, "HiHonor_") && + !absl::StartsWith(tag, "LivePhoto_")) { + break; + } + + const uint32_t payload_len = ReadLe32(bytes.data() + pos - kTlvFooterSize); + if (kTlvFooterSize + static_cast(payload_len) > pos) { + if (record_count == 0) { + if (error_message) { + *error_message = "First TLV record length exceeds trailer size"; + } + return false; + } + break; + } + + const size_t payload_pos = pos - kTlvFooterSize - payload_len; + out_meta->add_tlv_tags(std::string(tag)); + ++record_count; + + if (tag == "HiHonor_OfflineData" && payload_len > 4) { + absl::string_view json( + reinterpret_cast(bytes.data() + payload_pos + 4), + payload_len - 4); + int32_t scene_mode = 0; + if (ExtractJsonInt32(json, "sceneMode", &scene_mode)) { + out_meta->set_scene_mode(scene_mode); + } + } + + if (!out_meta->has_embedded_video() && + HasIsoBmffAt(bytes, payload_pos, payload_len)) { + out_meta->set_has_embedded_video(true); + out_meta->set_video_offset(payload_pos); + out_meta->set_video_length(payload_len); + } + + pos = payload_pos; + } + + // If an MP4 precedes the TLV chain directly after EOI ([0 .. pos)), detect + // it as well. + if (!out_meta->has_embedded_video() && pos >= 16 && + HasIsoBmffAt(bytes, 0, pos)) { + out_meta->set_has_embedded_video(true); + out_meta->set_video_offset(0); + out_meta->set_video_length(pos); + } + + return record_count > 0; + } + + if (error_message) { + *error_message = "Unrecognized Honor/Huawei trailer signature"; + } + return false; +} + +} // namespace + +bool HonorLivePhotoProvider::Identify(const RawMetadataBlock& block) { + if (block.format_identifier != "container.trailer" || + block.bytes.size() < kTagWidth) { + return false; + } + absl::string_view tail_20 = TrimPadding(absl::string_view( + reinterpret_cast(block.bytes.data() + block.bytes.size() - + kTagWidth), + kTagWidth)); + return absl::StartsWith(tail_20, "HiHonor_") || + absl::StartsWith(tail_20, "LivePhoto_") || + absl::StartsWith(tail_20, "LIVE_"); +} + +bool HonorLivePhotoProvider::DecodeToSemantic( + const RawMetadataBlock& block, std::vector* out_proto_bytes, + std::string* out_type_url, std::string* error_message) { + HonorLivePhotoMetadata metadata; + if (!ParseHonorTrailer(block, &metadata, error_message)) { + return false; + } + + if (out_type_url) { + *out_type_url = + "type.googleapis.com/libmotionphoto.motion_photo." + "HonorLivePhotoMetadata"; + } + if (out_proto_bytes) { + out_proto_bytes->resize(metadata.ByteSizeLong()); + if (!metadata.SerializeToArray(out_proto_bytes->data(), + out_proto_bytes->size())) { + if (error_message) { + *error_message = "Failed to serialize HonorLivePhotoMetadata proto"; + } + return false; + } + } + return true; +} + +bool HonorLivePhotoProvider::IsMotionPhoto( + const std::string& type_url, const std::string& payload_bytes) const { + if (!absl::StrContains(type_url, "HonorLivePhotoMetadata")) { + return false; + } + HonorLivePhotoMetadata metadata; + if (!metadata.ParseFromString(payload_bytes)) { + return false; + } + return metadata.has_embedded_video() && metadata.video_length() > 0; +} + +bool HonorLivePhotoProvider::GetVideoInfo(const RawMetadataBlock& block, + size_t* out_offset_in_block, + size_t* out_length) const { + HonorLivePhotoMetadata metadata; + if (!ParseHonorTrailer(block, &metadata, nullptr)) { + return false; + } + if (!metadata.has_embedded_video() || metadata.video_length() == 0) { + return false; + } + if (out_offset_in_block) { + *out_offset_in_block = static_cast(metadata.video_offset()); + } + if (out_length) { + *out_length = static_cast(metadata.video_length()); + } + return true; +} + +} // namespace motion_photo +} // namespace libmotionphoto diff --git a/lib/core/plugins/honor_live_photo/honor_live_photo_provider.h b/lib/core/plugins/honor_live_photo/honor_live_photo_provider.h new file mode 100644 index 0000000..85a7115 --- /dev/null +++ b/lib/core/plugins/honor_live_photo/honor_live_photo_provider.h @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef LIBMOTIONPHOTO_PLUGINS_HONOR_LIVE_PHOTO_HONOR_LIVE_PHOTO_PROVIDER_H_ +#define LIBMOTIONPHOTO_PLUGINS_HONOR_LIVE_PHOTO_HONOR_LIVE_PHOTO_PROVIDER_H_ + +#include +#include +#include +#include + +#include "motion_photo/metadata_block.h" +#include "motion_photo/metadata_provider.h" + +namespace libmotionphoto { +namespace motion_photo { + +// 3P plugin provider for Honor / Huawei Moving Picture ("HiHonor_*", +// "LivePhoto_*", and "LIVE_" trailer formats). +class HonorLivePhotoProvider : public IMetadataProvider { + public: + HonorLivePhotoProvider() = default; + ~HonorLivePhotoProvider() override = default; + + std::string GetPluginName() const override { return "honor_live_photo"; } + + bool Identify(const RawMetadataBlock& block) override; + + bool DecodeToSemantic(const RawMetadataBlock& block, + std::vector* out_proto_bytes, + std::string* out_type_url, + std::string* error_message) override; + + bool IsMotionPhoto(const std::string& type_url, + const std::string& payload_bytes) const override; + + bool GetVideoInfo(const RawMetadataBlock& block, size_t* out_offset_in_block, + size_t* out_length) const override; +}; + +} // namespace motion_photo +} // namespace libmotionphoto + +#endif // LIBMOTIONPHOTO_PLUGINS_HONOR_LIVE_PHOTO_HONOR_LIVE_PHOTO_PROVIDER_H_ diff --git a/lib/core/plugins/honor_live_photo/honor_live_photo_provider_test.cc b/lib/core/plugins/honor_live_photo/honor_live_photo_provider_test.cc new file mode 100644 index 0000000..e8858f5 --- /dev/null +++ b/lib/core/plugins/honor_live_photo/honor_live_photo_provider_test.cc @@ -0,0 +1,282 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "plugins/honor_live_photo/honor_live_photo_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "honor_live_photo_metadata.pb.h" // NOLINT +#include "libmotionphoto_api.h" +#include "motion_photo/metadata_block.h" +#include "motion_photo/metadata_engine.h" +#include "motion_photo/tests/test_framework.h" + +namespace libmotionphoto { +namespace motion_photo { +namespace { + +void AppendBe32(std::vector* buf, uint32_t v) { + buf->push_back(static_cast((v >> 24) & 0xFF)); + buf->push_back(static_cast((v >> 16) & 0xFF)); + buf->push_back(static_cast((v >> 8) & 0xFF)); + buf->push_back(static_cast(v & 0xFF)); +} + +void AppendLe32(std::vector* buf, uint32_t v) { + buf->push_back(static_cast(v & 0xFF)); + buf->push_back(static_cast((v >> 8) & 0xFF)); + buf->push_back(static_cast((v >> 16) & 0xFF)); + buf->push_back(static_cast((v >> 24) & 0xFF)); +} + +void AppendStr(std::vector* buf, const std::string& s) { + buf->insert(buf->end(), s.begin(), s.end()); +} + +void AppendTag20(std::vector* buf, const std::string& tag, + char pad_char = '\0') { + std::string padded = tag; + padded.resize(20, pad_char); + AppendStr(buf, padded); +} + +void AppendHonorTlvRecord(std::vector* buf, + const std::vector& payload, + const std::string& tag) { + buf->insert(buf->end(), payload.begin(), payload.end()); + AppendLe32(buf, static_cast(payload.size())); + AppendTag20(buf, tag, '\0'); +} + +std::vector BuildFakeMp4Payload() { + std::vector mp4; + AppendBe32(&mp4, 24); + AppendStr(&mp4, "ftypmp42"); + AppendBe32(&mp4, 0); + AppendStr(&mp4, "mp42isom"); + AppendBe32(&mp4, 16); + AppendStr(&mp4, "mdat"); + AppendStr(&mp4, "HONORMP4"); + return mp4; +} + +TEST(HonorLivePhotoProviderTest, IdentifiesHiHonorLivePhotoAndLiveFooters) { + HonorLivePhotoProvider provider; + EXPECT_EQ(provider.GetPluginName(), "honor_live_photo"); + + RawMetadataBlock hihonor_block; + hihonor_block.format_identifier = "container.trailer"; + AppendHonorTlvRecord(&hihonor_block.bytes, {'1', '2', '3', '4'}, + "HiHonor_CIID"); + EXPECT_TRUE(provider.Identify(hihonor_block)); + + RawMetadataBlock livephoto_block; + livephoto_block.format_identifier = "container.trailer"; + AppendHonorTlvRecord(&livephoto_block.bytes, {'1', '0', '0', '0'}, + "LivePhoto_1000"); + EXPECT_TRUE(provider.Identify(livephoto_block)); + + RawMetadataBlock live_footer_block; + live_footer_block.format_identifier = "container.trailer"; + std::vector mp4 = BuildFakeMp4Payload(); + live_footer_block.bytes = mp4; + AppendTag20(&live_footer_block.bytes, + "LIVE_" + std::to_string(mp4.size()), ' '); + EXPECT_TRUE(provider.Identify(live_footer_block)); + + RawMetadataBlock non_honor; + non_honor.format_identifier = "container.trailer"; + non_honor.bytes = mp4; + EXPECT_FALSE(provider.Identify(non_honor)); +} + +TEST(HonorLivePhotoProviderTest, + DecodesMagicOsBackwardTlvChainWithAndWithoutVideo) { + HonorLivePhotoProvider provider; + + // 1. Metadata-only Honor TLV chain (as observed on Honor MagicPad 2 YLE-W09 + // with HiHonor_OfflineData sceneMode:73, without embedded MP4). + std::vector ehdr_payload(16, 0); + std::string json = + R"({"common":{"jpegName":"IMG_20261001_112025","sceneMode":73}})"; + std::vector offline_payload; + AppendLe32(&offline_payload, static_cast(json.size())); + AppendStr(&offline_payload, json); + std::vector ciid_payload(40, '0'); + + RawMetadataBlock meta_only_block; + meta_only_block.format_identifier = "container.trailer"; + AppendHonorTlvRecord(&meta_only_block.bytes, ehdr_payload, "HiHonor_EhdrEn"); + AppendHonorTlvRecord(&meta_only_block.bytes, offline_payload, + "HiHonor_OfflineData"); + AppendHonorTlvRecord(&meta_only_block.bytes, ciid_payload, "HiHonor_CIID"); + + std::vector proto_bytes; + std::string type_url; + std::string err; + ASSERT_TRUE(provider.DecodeToSemantic(meta_only_block, &proto_bytes, + &type_url, &err)); + EXPECT_EQ(type_url, + "type.googleapis.com/libmotionphoto.motion_photo." + "HonorLivePhotoMetadata"); + + std::string payload_str(proto_bytes.begin(), proto_bytes.end()); + // Without an embedded MP4 stream, IsMotionPhoto returns false. + EXPECT_FALSE(provider.IsMotionPhoto(type_url, payload_str)); + + HonorLivePhotoMetadata meta; + ASSERT_TRUE(meta.ParseFromString(payload_str)); + EXPECT_EQ(meta.format_variant(), "hihonor_tlv"); + EXPECT_FALSE(meta.has_embedded_video()); + EXPECT_EQ(meta.scene_mode(), 73); + ASSERT_EQ(meta.tlv_tags_size(), 3); + EXPECT_EQ(meta.tlv_tags(0), "HiHonor_CIID"); + EXPECT_EQ(meta.tlv_tags(1), "HiHonor_OfflineData"); + EXPECT_EQ(meta.tlv_tags(2), "HiHonor_EhdrEn"); + + // 2. Honor TLV chain containing an embedded MP4 record (HiHonor_1000000). + std::vector mp4 = BuildFakeMp4Payload(); + RawMetadataBlock video_tlv_block; + video_tlv_block.format_identifier = "container.trailer"; + AppendHonorTlvRecord(&video_tlv_block.bytes, mp4, "HiHonor_1000000"); + AppendHonorTlvRecord(&video_tlv_block.bytes, offline_payload, + "HiHonor_OfflineData"); + AppendHonorTlvRecord(&video_tlv_block.bytes, ciid_payload, "HiHonor_CIID"); + + proto_bytes.clear(); + ASSERT_TRUE(provider.DecodeToSemantic(video_tlv_block, &proto_bytes, + &type_url, &err)); + std::string video_payload_str(proto_bytes.begin(), proto_bytes.end()); + EXPECT_TRUE(provider.IsMotionPhoto(type_url, video_payload_str)); + + size_t video_offset = 999; + size_t video_len = 0; + ASSERT_TRUE( + provider.GetVideoInfo(video_tlv_block, &video_offset, &video_len)); + EXPECT_EQ(video_offset, 0u); + EXPECT_EQ(video_len, mp4.size()); +} + +TEST(HonorLivePhotoProviderTest, DecodesHuaweiLiveFooterAndExtractsVideo) { + HonorLivePhotoProvider provider; + std::vector mp4 = BuildFakeMp4Payload(); + + // Build trailer: [MP4][20B cover offset in ms "450 "][20B "LIVE_40 "] + RawMetadataBlock live_block; + live_block.format_identifier = "container.trailer"; + live_block.bytes = mp4; + AppendTag20(&live_block.bytes, "450", ' '); + AppendTag20(&live_block.bytes, "LIVE_" + std::to_string(mp4.size()), ' '); + + std::vector proto_bytes; + std::string type_url; + ASSERT_TRUE( + provider.DecodeToSemantic(live_block, &proto_bytes, &type_url, nullptr)); + HonorLivePhotoMetadata meta; + ASSERT_TRUE(meta.ParseFromArray(proto_bytes.data(), proto_bytes.size())); + EXPECT_EQ(meta.format_variant(), "live_footer"); + EXPECT_TRUE(meta.has_embedded_video()); + EXPECT_EQ(meta.video_offset(), 0u); + EXPECT_EQ(meta.video_length(), mp4.size()); + EXPECT_EQ(meta.primary_image_timestamp_us(), 450000); +} + +TEST(HonorLivePhotoProviderTest, EndToEndHonorJpegSplitAndExtract) { + std::vector primary_jpeg = {0xFF, 0xD8, 0xFF, 0xD9}; + std::vector mp4 = BuildFakeMp4Payload(); + + std::vector trailer; + AppendHonorTlvRecord(&trailer, mp4, "HiHonor_1000000"); + AppendHonorTlvRecord(&trailer, {'0', '0', '0', '1'}, "HiHonor_CIID"); + + std::vector full_file = primary_jpeg; + full_file.insert(full_file.end(), trailer.begin(), trailer.end()); + + libmotionphoto::api::MotionPhotoMetadata meta; + libmotionphoto::api::HandlerOptions opts; + EXPECT_TRUE(libmotionphoto::api::ParseMotionPhotoFromMemory( + full_file.data(), full_file.size(), &meta, nullptr, opts)); + EXPECT_TRUE(meta.is_motion_photo); + EXPECT_EQ(meta.video_length, mp4.size()); + + libmotionphoto::api::HandlerOptions disabled_opts; + disabled_opts.disable_3p_plugins = true; + libmotionphoto::api::MotionPhotoMetadata disabled_meta; + EXPECT_FALSE(libmotionphoto::api::ParseMotionPhotoFromMemory( + full_file.data(), full_file.size(), &disabled_meta, nullptr, + disabled_opts)); + + const std::string in_path = "/tmp/honor_test_input.jpg"; + const std::string out_img = "/tmp/honor_test_primary.jpg"; + const std::string out_vid = "/tmp/honor_test_video.mp4"; + { + std::ofstream out(in_path, std::ios::binary); + out.write(reinterpret_cast(full_file.data()), + full_file.size()); + } + + libmotionphoto::api::ExtractorParams extract_params; + extract_params.motion_photo_filepath = in_path; + extract_params.primary_image_output_path = out_img; + extract_params.video_output_path = out_vid; + ASSERT_TRUE(libmotionphoto::api::ExtractMotionPhotoFromFile(extract_params)); + + std::ifstream img_in(out_img, std::ios::binary); + std::vector extracted_img((std::istreambuf_iterator(img_in)), + std::istreambuf_iterator()); + EXPECT_TRUE(extracted_img == primary_jpeg); + + std::ifstream vid_in(out_vid, std::ios::binary); + std::vector extracted_vid((std::istreambuf_iterator(vid_in)), + std::istreambuf_iterator()); + EXPECT_TRUE(extracted_vid == mp4); + + std::remove(in_path.c_str()); + std::remove(out_img.c_str()); + std::remove(out_vid.c_str()); +} + +TEST(HonorLivePhotoProviderTest, RejectsMalformedTlvAndLiveFootersSafely) { + HonorLivePhotoProvider provider; + + // TLV record whose u32LE length exceeds the trailer size. + RawMetadataBlock bad_tlv; + bad_tlv.format_identifier = "container.trailer"; + AppendLe32(&bad_tlv.bytes, 0x00FFFFFF); + AppendTag20(&bad_tlv.bytes, "HiHonor_CIID", '\0'); + EXPECT_TRUE(provider.Identify(bad_tlv)); + + std::vector proto; + std::string url; + std::string err; + EXPECT_FALSE(provider.DecodeToSemantic(bad_tlv, &proto, &url, &err)); + + // LIVE_ footer with invalid size. + RawMetadataBlock bad_live; + bad_live.format_identifier = "container.trailer"; + AppendTag20(&bad_live.bytes, "LIVE_9999999", ' '); + EXPECT_TRUE(provider.Identify(bad_live)); + EXPECT_FALSE(provider.DecodeToSemantic(bad_live, &proto, &url, &err)); +} + +} // namespace +} // namespace motion_photo +} // namespace libmotionphoto