diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 38354ed..2cb337d 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -17,6 +17,37 @@ est acceptée sans nouvelle ligne ni migration de schéma. Elle ne crée aucune image logique, ne modifie aucune sélection et ne réalise aucun appariement automatique. +### S3-E — Orchestration d'ingestion multi-source v1 + +**IMPLEMENTED / VALIDATION PENDING.** S3-E reçoit un ScanSet explicite et au +plus 64 chemins source explicitement fournis par l'appelant ; il ne parcourt +jamais un répertoire. Chaque fichier est d'abord publié comme asset immuable +géré, puis son évidence S3-D est extraite depuis ces octets publiés. Le +regroupement automatique ne crée un Capture commun que pour une relation +`SAME_ACQUISITION_STRONG` unique et mutuelle. Les noms, chemins, SHA-256, +timestamps, modèle, évidence faible, contradictions et ambiguïtés ne sont +jamais une identité de Capture et ne donnent jamais lieu à un rapprochement +automatique. Deux assets identiques dans une requête sont rejetés de façon +déterministe. + +L'appelant peut aussi déclarer explicitement des groupes. Ce résultat porte la +basis `CALLER_EXPLICIT`, distincte de la basis scientifique `STRONG`; cette +provenance est un résultat de l'opération v1, non une nouvelle colonne durable. +Les siblings sont persistés exclusivement avec S3-C. Pour la représentation, +un JPEG caméra reste un asset `SOURCE` et devient une image logique du même +Capture via la publication source-image transactionnelle; un RAW reste +`SOURCE` et son image est produite seulement par S3-B1, avec son PNG `DERIVED`. +RAW et JPEG d'une même acquisition ne créent pas deux Captures. La sélection ne +change que si l'appelant demande explicitement la représentation sélectionnée. + +Project DB reste strictement v19, sans migration ni fusion de Captures. Après +qu'un Capture ID a été renvoyé ou durablement retenu, une reprise doit fournir +explicitement `resume_capture_id`: publication, attaches S3-C et représentation +convergent alors sans réhoming ni doublon logique. Une panne avant que +l'appelant ait retenu cet ID ne peut pas offrir une reprise whole-request +exactly-once en v19; S3-E n'infère volontairement jamais une identité de +reprise depuis SHA, chemin, basename, timestamp, métadonnées ou `image_id`. + `asset_derivations` est volontairement limité à un parent asset et un enfant asset, avec kind/version et fingerprint canonique de 32 octets. Il n'est pas un DAG générique et ne réalise aucun développement RAW ni extraction vidéo. La @@ -44,9 +75,8 @@ assets sont différents. Une valeur absente n'est pas un conflit. Une ambiguït n'est jamais résolue par l'ordre des assets. S3-D n'effectue aucune écriture en base ni changement de schéma : Project DB -reste v19 et S3-C attach demeure l'unique primitive de persistance. S3-E, la -future intégration d'ingestion multi-source avant `Capture`, n'est pas -implémentée. +reste v19 et S3-C attach demeure l'unique primitive de persistance. S3-E +réutilise cette évidence sans la redéfinir. ## Phase H — décision Project DB v18 diff --git a/include/lardon3d/acquisition_ingest.h b/include/lardon3d/acquisition_ingest.h new file mode 100644 index 0000000..0cc6aef --- /dev/null +++ b/include/lardon3d/acquisition_ingest.h @@ -0,0 +1,81 @@ +#ifndef LARDON3D_ACQUISITION_INGEST_H +#define LARDON3D_ACQUISITION_INGEST_H + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LARDON3D_ACQUISITION_INGEST_MAX_SOURCES 64u + +typedef enum { + LARDON3D_ACQUISITION_INGEST_OK = 0, + LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT, + LARDON3D_ACQUISITION_INGEST_SCANSET_NOT_FOUND, + LARDON3D_ACQUISITION_INGEST_PUBLISH_ERROR, + LARDON3D_ACQUISITION_INGEST_METADATA_ERROR, + LARDON3D_ACQUISITION_INGEST_DUPLICATE_ASSET, + LARDON3D_ACQUISITION_INGEST_DB_ERROR, + LARDON3D_ACQUISITION_INGEST_DEVELOPMENT_ERROR, + LARDON3D_ACQUISITION_INGEST_CONSTRAINT, + LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR +} Lardon3DAcquisitionIngestResult; + +typedef enum { + LARDON3D_ACQUISITION_GROUP_AUTOMATIC = 1, + LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT = 2 +} Lardon3DAcquisitionGroupingMode; + +typedef enum { + LARDON3D_ACQUISITION_GROUP_SINGLETON = 0, + LARDON3D_ACQUISITION_GROUP_STRONG = 1, + LARDON3D_ACQUISITION_GROUP_EXPLICIT = 2 +} Lardon3DAcquisitionGroupBasis; + +typedef enum { + LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE = 1, + LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW = 2 +} Lardon3DAcquisitionRepresentation; + +typedef struct { + const char *source_path; /* Borrowed for the duration of the call. */ + uint32_t explicit_group; +} Lardon3DAcquisitionIngestSource; + +typedef struct { + Lardon3DAcquisitionGroupingMode grouping; + Lardon3DAcquisitionRepresentation representation; + uint8_t select_representation; + uint8_t reserved[7]; + uint64_t resume_capture_id; + uint64_t producer_task_id; + int64_t imported_at; + uint64_t max_source_bytes; +} Lardon3DAcquisitionIngestOptions; + +typedef struct { + uint64_t capture_id; + Lardon3DAcquisitionGroupBasis basis; + size_t source_count; + size_t source_indices[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]; +} Lardon3DAcquisitionIngestGroup; + +typedef struct { + size_t group_count; + Lardon3DAcquisitionIngestGroup groups[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]; +} Lardon3DAcquisitionIngestOutput; + +Lardon3DAcquisitionIngestResult lardon3d_acquisition_ingest( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DAcquisitionIngestSource *sources, size_t source_count, + const Lardon3DAcquisitionIngestOptions *options, Lardon3DAcquisitionIngestOutput *output); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/project_db.h b/include/lardon3d/project_db.h index bd223af..9409bce 100644 --- a/include/lardon3d/project_db.h +++ b/include/lardon3d/project_db.h @@ -510,6 +510,11 @@ Lardon3DProjectDbResult lardon3d_project_db_publish_derived_capture_image( const char *original_name, const char *source_path, uint64_t producer_task_id, int64_t imported_at, Lardon3DProjectDbImageRegisterStatus *status, Lardon3DProjectDbImage *image); +Lardon3DProjectDbResult lardon3d_project_db_publish_source_capture_image( + Lardon3DProjectDb *database, uint64_t capture_id, uint64_t asset_id, + const char *original_name, const char *source_path, uint64_t producer_task_id, + int64_t imported_at, bool select_image, Lardon3DProjectDbImageRegisterStatus *status, + Lardon3DProjectDbImage *image); Lardon3DProjectDbResult lardon3d_project_db_load_image(Lardon3DProjectDb *database, uint64_t image_id, Lardon3DProjectDbImage *image, diff --git a/meson.build b/meson.build index 32b9844..fb6fa93 100644 --- a/meson.build +++ b/meson.build @@ -150,6 +150,7 @@ executable( 'src/image_catalog_persistent.c', 'src/raw_development.cpp', 'src/acquisition_pairing.cpp', + 'src/acquisition_ingest.cpp', 'src/feature_extractor_opencv.cpp', 'src/feature_store.c', 'src/feature_extractor_opencv.cpp', @@ -333,6 +334,27 @@ acquisition_pairing_test = executable( test('acquisition-pairing', acquisition_pairing_test, timeout: 30) +acquisition_ingest_test = executable( + 'test-acquisition-ingest', + sources: [ + 'tests/test_acquisition_ingest.cpp', + 'src/app_state.c', + 'src/image_catalog_persistent.c', + 'src/acquisition_ingest.cpp', + 'src/acquisition_pairing.cpp', + 'src/raw_development.cpp', + 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', + 'src/resource_governor.c', + 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + cpp_args: ['-DLARDON3D_ACQUISITION_INGEST_TESTING'], + dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate], +) + +test('acquisition-ingest', acquisition_ingest_test, timeout: 30) + feature_store_test = executable( 'test-feature-store', sources: [ diff --git a/src/acquisition_ingest.cpp b/src/acquisition_ingest.cpp new file mode 100644 index 0000000..38f58f1 --- /dev/null +++ b/src/acquisition_ingest.cpp @@ -0,0 +1,195 @@ +#include + +#include +extern "C" { +#include +} +#include + +#include +#include +#include + +namespace { + +#ifdef LARDON3D_ACQUISITION_INGEST_TESTING +const Lardon3DAcquisitionMetadata *testing_metadata = nullptr; +size_t testing_metadata_count = 0u; +#endif + +struct PublishedSource { + Lardon3DProjectDbImageAsset asset; + Lardon3DAcquisitionMetadata metadata; + char managed_path[PATH_MAX]; +}; + +bool valid_options(const Lardon3DAcquisitionIngestOptions &options) { + return (options.grouping == LARDON3D_ACQUISITION_GROUP_AUTOMATIC || + options.grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) && + (options.representation == LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE || + options.representation == LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW) && + options.select_representation <= 1u && options.imported_at >= 0 && + options.max_source_bytes != 0u; +} + +const char *base_name(const char *path) { + const char *slash = std::strrchr(path, '/'); + return slash == nullptr ? path : slash + 1; +} + +Lardon3DAcquisitionIngestResult db_result(Lardon3DProjectDbResult result) { + if (result == LARDON3D_PROJECT_DB_CONSTRAINT) return LARDON3D_ACQUISITION_INGEST_CONSTRAINT; + return result == LARDON3D_PROJECT_DB_NOT_FOUND ? LARDON3D_ACQUISITION_INGEST_SCANSET_NOT_FOUND + : LARDON3D_ACQUISITION_INGEST_DB_ERROR; +} + +bool add_to_group(Lardon3DAcquisitionIngestGroup &group, size_t source_index) { + if (group.source_count >= LARDON3D_ACQUISITION_INGEST_MAX_SOURCES) return false; + group.source_indices[group.source_count++] = source_index; + return true; +} + +} // namespace + +#ifdef LARDON3D_ACQUISITION_INGEST_TESTING +extern "C" void lardon3d_acquisition_ingest_test_set_metadata( + const Lardon3DAcquisitionMetadata *metadata, size_t count) { + testing_metadata = metadata; + testing_metadata_count = count; +} +#endif + +extern "C" Lardon3DAcquisitionIngestResult lardon3d_acquisition_ingest( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DAcquisitionIngestSource *sources, size_t source_count, + const Lardon3DAcquisitionIngestOptions *options, Lardon3DAcquisitionIngestOutput *output) { + if (output != nullptr) std::memset(output, 0, sizeof(*output)); + if (state == nullptr || !state->project_loaded || state->project_db == nullptr || scanset_id == 0u || + sources == nullptr || source_count == 0u || + source_count > LARDON3D_ACQUISITION_INGEST_MAX_SOURCES || options == nullptr || + !valid_options(*options) || output == nullptr) return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + Lardon3DProjectDbScanSet scanset{}; + Lardon3DProjectDbResult database_result = + lardon3d_project_db_load_scanset(state->project_db, scanset_id, &scanset); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + if (options->grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) { + for (size_t i = 0; i < source_count; ++i) + if (sources[i].explicit_group == 0u) return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + } + + PublishedSource published[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{}; + for (size_t i = 0; i < source_count; ++i) { + if (sources[i].source_path == nullptr || sources[i].source_path[0] == '\0') + return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + if (lardon3d_image_catalog_publish_asset_file(state, sources[i].source_path, + options->imported_at, options->max_source_bytes, &published[i].asset) != + LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED) return LARDON3D_ACQUISITION_INGEST_PUBLISH_ERROR; + for (size_t previous = 0; previous < i; ++previous) + if (published[previous].asset.asset_id == published[i].asset.asset_id) + return LARDON3D_ACQUISITION_INGEST_DUPLICATE_ASSET; + int length = std::snprintf(published[i].managed_path, sizeof(published[i].managed_path), + "%s/%s", state->project_path, published[i].asset.path); + if (length <= 0 || static_cast(length) >= sizeof(published[i].managed_path)) + return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + if (lardon3d_acquisition_extract_metadata(published[i].managed_path, + &published[i].metadata) != LARDON3D_ACQUISITION_OK) + return LARDON3D_ACQUISITION_INGEST_METADATA_ERROR; +#ifdef LARDON3D_ACQUISITION_INGEST_TESTING + if (testing_metadata != nullptr) { + if (testing_metadata_count != source_count) return LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR; + published[i].metadata = testing_metadata[i]; + } +#endif + } + + bool assigned[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{}; + if (options->grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) { + for (size_t i = 0; i < source_count; ++i) { + if (assigned[i]) continue; + Lardon3DAcquisitionIngestGroup &group = output->groups[output->group_count++]; + group.basis = LARDON3D_ACQUISITION_GROUP_EXPLICIT; + for (size_t j = i; j < source_count; ++j) { + if (sources[j].explicit_group == sources[i].explicit_group) { + (void)add_to_group(group, j); + assigned[j] = true; + } + } + } + } else { + size_t strong_count[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{}; + size_t strong_partner[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{}; + for (size_t i = 0; i < source_count; ++i) for (size_t j = i + 1; j < source_count; ++j) { + Lardon3DAcquisitionPairResult pair{}; + if (lardon3d_acquisition_compare(&published[i].metadata, &published[j].metadata, 0, 0, + &pair) != LARDON3D_ACQUISITION_OK) + return LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR; + if (pair.decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG) { + ++strong_count[i]; ++strong_count[j]; strong_partner[i] = j; strong_partner[j] = i; + } + } + for (size_t i = 0; i < source_count; ++i) { + if (assigned[i]) continue; + Lardon3DAcquisitionIngestGroup &group = output->groups[output->group_count++]; + (void)add_to_group(group, i); assigned[i] = true; + size_t partner = strong_partner[i]; + if (strong_count[i] == 1u && strong_count[partner] == 1u && strong_partner[partner] == i) { + (void)add_to_group(group, partner); assigned[partner] = true; + group.basis = LARDON3D_ACQUISITION_GROUP_STRONG; + } else group.basis = LARDON3D_ACQUISITION_GROUP_SINGLETON; + } + } + if (options->resume_capture_id != 0u && output->group_count != 1u) + return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + + for (size_t group_index = 0; group_index < output->group_count; ++group_index) { + Lardon3DAcquisitionIngestGroup &group = output->groups[group_index]; + Lardon3DProjectDbCapture capture{}; + if (options->resume_capture_id != 0u) { + database_result = lardon3d_project_db_load_capture(state->project_db, + options->resume_capture_id, &capture); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + if (capture.scanset_id != scanset_id) return LARDON3D_ACQUISITION_INGEST_CONSTRAINT; + } else { + database_result = lardon3d_project_db_create_capture(state->project_db, scanset_id, + options->imported_at, &capture); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + } + group.capture_id = capture.capture_id; + size_t jpeg = SIZE_MAX, raw = SIZE_MAX; + for (size_t member = 0; member < group.source_count; ++member) { + size_t index = group.source_indices[member]; + database_result = lardon3d_project_db_attach_capture_source_asset( + state->project_db, capture.capture_id, published[index].asset.asset_id); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + if (published[index].metadata.source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG && jpeg == SIZE_MAX) + jpeg = index; + if (published[index].metadata.source_kind == LARDON3D_ACQUISITION_SOURCE_RAW && raw == SIZE_MAX) + raw = index; + } + bool use_jpeg = jpeg != SIZE_MAX && + (options->representation == LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE || raw == SIZE_MAX); + if (use_jpeg) { + Lardon3DProjectDbImage image{}; + Lardon3DProjectDbImageRegisterStatus status{}; + database_result = lardon3d_project_db_publish_source_capture_image( + state->project_db, capture.capture_id, published[jpeg].asset.asset_id, + base_name(sources[jpeg].source_path), sources[jpeg].source_path, + options->producer_task_id, options->imported_at, options->select_representation != 0u, + &status, &image); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + } else if (raw != SIZE_MAX) { + Lardon3DRawDevelopmentOutput developed{}; + Lardon3DRawDevelopmentResult result = lardon3d_raw_develop_to_capture( + state, capture.capture_id, published[raw].managed_path, options->producer_task_id, + options->imported_at, &developed); + if (result != LARDON3D_RAW_DEVELOPMENT_OK) + return LARDON3D_ACQUISITION_INGEST_DEVELOPMENT_ERROR; + if (options->select_representation) { + database_result = lardon3d_project_db_set_selected_capture_image( + state->project_db, capture.capture_id, developed.image.image_id); + if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result); + } + } else return LARDON3D_ACQUISITION_INGEST_CONSTRAINT; + } + return LARDON3D_ACQUISITION_INGEST_OK; +} diff --git a/src/project_db.c b/src/project_db.c index 7308b18..bf83094 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -2950,6 +2950,128 @@ Lardon3DProjectDbResult lardon3d_project_db_publish_derived_capture_image( &(Lardon3DProjectDbImageAsset){0}); } +Lardon3DProjectDbResult lardon3d_project_db_publish_source_capture_image( + Lardon3DProjectDb *database, uint64_t capture_id, uint64_t asset_id, + const char *original_name, const char *source_path, uint64_t producer_task_id, + int64_t imported_at, bool select_image, Lardon3DProjectDbImageRegisterStatus *status, + Lardon3DProjectDbImage *image) { + if (!database || !valid_catalog_id(capture_id) || !valid_catalog_id(asset_id) || + !valid_original_name(original_name) || + !bounded_text(source_path, LARDON3D_PROJECT_DB_PATH_CAPACITY, false) || + (producer_task_id != 0 && !valid_task_id(producer_task_id)) || imported_at < 0 || !status || + !image) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + memset(image, 0, sizeof(*image)); + *status = LARDON3D_PROJECT_DB_IMAGE_REGISTERED; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = + execute(database, "BEGIN IMMEDIATE", "begin source capture image publication"); + sqlite3_stmt *statement = NULL; + sqlite3_int64 scanset_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) { + result = prepare(database, "SELECT scanset_id FROM captures WHERE capture_id=?1", &statement); + } + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW || (scanset_id = sqlite3_column_int64(statement, 0)) <= 0) + result = code == SQLITE_ROW ? LARDON3D_PROJECT_DB_CORRUPT + : sqlite_result(database, code, "load source image capture"); + (void)sqlite3_finalize(statement); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK) { + result = prepare(database, "SELECT 1 FROM image_assets WHERE asset_id=?1 AND state=1", &statement); + } + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)asset_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load source image asset"); + (void)sqlite3_finalize(statement); + statement = NULL; + } + int inserted = 0; + uint64_t image_id = 0; + if (result == LARDON3D_PROJECT_DB_OK) { + result = insert_or_find_image_locked(database, (uint64_t)scanset_id, asset_id, original_name, + source_path, producer_task_id, imported_at, &inserted, + &image_id); + } + if (result == LARDON3D_PROJECT_DB_OK) { + result = prepare(database, "SELECT capture_id FROM capture_images WHERE image_id=?1", &statement); + } + bool attach_image = false; + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)image_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) attach_image = true; + else if (code != SQLITE_ROW) result = sqlite_result(database, code, "find source image capture"); + else if (sqlite3_column_int64(statement, 0) != (sqlite3_int64)capture_id) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + (void)sqlite3_finalize(statement); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK) { + result = prepare(database, "SELECT role FROM capture_assets WHERE capture_id=?1 AND asset_id=?2", + &statement); + } + bool attach_asset = false; + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)asset_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) attach_asset = true; + else if (code != SQLITE_ROW) result = sqlite_result(database, code, "find source capture asset"); + else if (sqlite3_column_int(statement, 0) != LARDON3D_DB_CAPTURE_ASSET_SOURCE) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + (void)sqlite3_finalize(statement); + statement = NULL; + } + if (result == LARDON3D_PROJECT_DB_OK && attach_asset) { + result = prepare(database, "INSERT INTO capture_assets(capture_id,asset_id,role) VALUES(?1,?2,1)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)asset_id); + result = step_done(database, statement, "attach source capture asset"); + statement = NULL; + } + } + if (result == LARDON3D_PROJECT_DB_OK && attach_image) { + result = prepare(database, "INSERT INTO capture_images(capture_id,image_id) VALUES(?1,?2)", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)image_id); + result = step_done(database, statement, "attach source capture image"); + statement = NULL; + } + } + if (result == LARDON3D_PROJECT_DB_OK && select_image) { + result = prepare(database, "INSERT INTO capture_selections(capture_id,image_id) VALUES(?1,?2) " + "ON CONFLICT(capture_id) DO UPDATE SET image_id=excluded.image_id", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id); + (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)image_id); + result = step_done(database, statement, "select source capture image"); + statement = NULL; + } + } + if (result == LARDON3D_PROJECT_DB_OK) + result = execute(database, "COMMIT", "commit source capture image publication"); + if (result != LARDON3D_PROJECT_DB_OK) + (void)execute(database, "ROLLBACK", "rollback source capture image publication"); + (void)pthread_mutex_unlock(&database->mutex); + if (result != LARDON3D_PROJECT_DB_OK) return result; + *status = inserted == 1 ? LARDON3D_PROJECT_DB_IMAGE_REGISTERED + : LARDON3D_PROJECT_DB_IMAGE_ALREADY_PRESENT; + return lardon3d_project_db_load_image(database, image_id, image, + &(Lardon3DProjectDbImageAsset){0}); +} + Lardon3DProjectDbResult lardon3d_project_db_load_image(Lardon3DProjectDb *database, uint64_t image_id, Lardon3DProjectDbImage *image, diff --git a/tests/test_acquisition_ingest.cpp b/tests/test_acquisition_ingest.cpp new file mode 100644 index 0000000..ccf9fd1 --- /dev/null +++ b/tests/test_acquisition_ingest.cpp @@ -0,0 +1,263 @@ +#include +#include + +extern "C" { +#include +} + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void lardon3d_acquisition_ingest_test_set_metadata( + const Lardon3DAcquisitionMetadata *metadata, size_t count); + +#define CHECK(condition) do { \ + if (!(condition)) { std::fprintf(stderr, "Failure line %d: %s\\n", __LINE__, #condition); return false; } \ +} while (0) + +namespace { + +bool remove_tree(const char *path) { + struct stat info {}; + if (lstat(path, &info) != 0) return errno == ENOENT; + if (!S_ISDIR(info.st_mode)) return unlink(path) == 0; + DIR *directory = opendir(path); + if (directory == nullptr) return false; + bool ok = true; + for (dirent *entry = readdir(directory); entry != nullptr; entry = readdir(directory)) { + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) continue; + char child[4096]; + const int length = std::snprintf(child, sizeof(child), "%s/%s", path, entry->d_name); + if (length <= 0 || static_cast(length) >= sizeof(child) || !remove_tree(child)) ok = false; + } + return closedir(directory) == 0 && rmdir(path) == 0 && ok; +} + +void add_ascii(ExifData *data, ExifIfd ifd, ExifTag tag, const char *text) { + ExifEntry *entry = exif_entry_new(); + entry->tag = tag; + entry->format = EXIF_FORMAT_ASCII; + entry->components = std::strlen(text) + 1u; + entry->size = static_cast(entry->components); + entry->data = static_cast(std::malloc(entry->size)); + std::memcpy(entry->data, text, entry->size); + exif_content_add_entry(data->ifd[ifd], entry); + exif_entry_unref(entry); +} + +bool write_jpeg(const char *path, const char *unique_id) { + ExifData *data = exif_data_new(); + if (data == nullptr) return false; + exif_data_set_byte_order(data, EXIF_BYTE_ORDER_INTEL); + add_ascii(data, EXIF_IFD_0, EXIF_TAG_MODEL, "Lardon test camera"); + if (unique_id != nullptr) add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_IMAGE_UNIQUE_ID, unique_id); + unsigned char *encoded = nullptr; + unsigned int encoded_size = 0u; + exif_data_save_data(data, &encoded, &encoded_size); + FILE *file = std::fopen(path, "wb"); + const unsigned char soi[] = {0xffu, 0xd8u, 0xffu, 0xe1u}; + const unsigned int segment_size = encoded_size + 2u; + const unsigned char length[] = {static_cast(segment_size >> 8u), + static_cast(segment_size)}; + const unsigned char eoi[] = {0xffu, 0xd9u}; + const bool ok = file != nullptr && encoded != nullptr && encoded_size != 0u && + segment_size <= UINT16_MAX && std::fwrite(soi, 1, sizeof(soi), file) == sizeof(soi) && + std::fwrite(length, 1, sizeof(length), file) == sizeof(length) && + std::fwrite(encoded, 1, encoded_size, file) == encoded_size && + std::fwrite(eoi, 1, sizeof(eoi), file) == sizeof(eoi) && std::fclose(file) == 0; + if (file != nullptr && !ok) std::fclose(file); + std::free(encoded); + exif_data_unref(data); + return ok; +} + +struct Fixture { + char root[4096] = "/tmp/lardon3d-ingest-XXXXXX"; + char database_path[4096]{}; + Lardon3DProjectDb *database = nullptr; + Lardon3DAppState state{}; + Lardon3DProjectDbScanSet scanset{}; + + bool open() { + if (mkdtemp(root) == nullptr || std::snprintf(database_path, sizeof(database_path), "%s/project.db", root) <= 0) + return false; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + if (lardon3d_project_db_open(database_path, &database, error) != LARDON3D_PROJECT_DB_OK) return false; + state.project_loaded = true; + state.project_db = database; + if (std::snprintf(state.project_path, sizeof(state.project_path), "%s", root) <= 0) return false; + return lardon3d_project_db_create_scanset(database, "S3-E", &scanset) == LARDON3D_PROJECT_DB_OK; + } + void close() { + lardon3d_acquisition_ingest_test_set_metadata(nullptr, 0u); + if (database != nullptr) lardon3d_project_db_close(database); + database = nullptr; + if (root[0] != '\0') (void)remove_tree(root); + } + bool path(char output[4096], const char *name) const { + const int length = std::snprintf(output, 4096, "%s/%s", root, name); + return length > 0 && length < 4096; + } +}; + +Lardon3DAcquisitionIngestOptions options() { + Lardon3DAcquisitionIngestOptions value{}; + value.grouping = LARDON3D_ACQUISITION_GROUP_AUTOMATIC; + value.representation = LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE; + value.imported_at = 7; + value.max_source_bytes = 1024 * 1024; + return value; +} + +Lardon3DAcquisitionMetadata metadata(const char *uid, Lardon3DAcquisitionSourceKind kind = LARDON3D_ACQUISITION_SOURCE_JPEG) { + Lardon3DAcquisitionMetadata value{}; + value.policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION; + value.source_kind = kind; + if (uid != nullptr) { + std::strcpy(value.image_unique_id, uid); + value.present_fields = LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID; + } + return value; +} + +size_t capture_count(Lardon3DProjectDb *database, uint64_t scanset_id) { + Lardon3DProjectDbCapture captures[64]{}; + size_t count = 0u; + return lardon3d_project_db_list_captures(database, scanset_id, 0, captures, 64, &count) == + LARDON3D_PROJECT_DB_OK ? count : SIZE_MAX; +} + +bool capture_assets(Lardon3DProjectDb *database, uint64_t capture_id, size_t expected) { + Lardon3DProjectDbCaptureAsset assets[64]{}; + size_t count = 0u; + if (lardon3d_project_db_list_capture_assets(database, capture_id, 0, assets, 64, &count) != + LARDON3D_PROJECT_DB_OK || count != expected) return false; + for (size_t i = 0; i < count; ++i) + if (assets[i].role != LARDON3D_DB_CAPTURE_ASSET_SOURCE) return false; + return true; +} + +bool test_ingest_grouping_and_resume() { + Fixture fixture; + CHECK(fixture.open()); + char a[4096], b[4096], c[4096], d[4096]; + CHECK(fixture.path(a, "DSC03350.JPG") && fixture.path(b, "DSC03350.ARW") && + fixture.path(c, "other.JPG") && fixture.path(d, "fourth.JPG")); + CHECK(write_jpeg(a, "A") && write_jpeg(b, "B") && write_jpeg(c, "C") && write_jpeg(d, "D")); + Lardon3DAcquisitionIngestSource sources[] = {{a, 0}, {b, 0}}; + Lardon3DAcquisitionIngestOutput output{}; + Lardon3DAcquisitionIngestOptions automatic_options = options(); + + /* Same basename and second-level timestamp are not passed as pairing hints. */ + Lardon3DAcquisitionMetadata weak[] = {metadata(nullptr), metadata(nullptr)}; + lardon3d_acquisition_ingest_test_set_metadata(weak, 2); + CHECK(lardon3d_acquisition_ingest(&fixture.state, fixture.scanset.scanset_id, sources, 2, + &automatic_options, &output) == LARDON3D_ACQUISITION_INGEST_OK); + CHECK(output.group_count == 2 && output.groups[0].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON && + output.groups[1].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON && capture_count(fixture.database, fixture.scanset.scanset_id) == 2); + + /* Controlled S3-D evidence: distinct source kinds, one unique mutual strong pair. */ + Fixture strong; + CHECK(strong.open()); + char raw_like[4096], jpeg[4096]; + CHECK(strong.path(raw_like, "raw-like.bin") && strong.path(jpeg, "camera.JPG") && + write_jpeg(raw_like, "physical-one") && write_jpeg(jpeg, "physical-two")); + Lardon3DAcquisitionIngestSource pair[] = {{raw_like, 0}, {jpeg, 0}}; + Lardon3DAcquisitionMetadata same[] = {metadata("SHUTTER", LARDON3D_ACQUISITION_SOURCE_RAW), + metadata("SHUTTER", LARDON3D_ACQUISITION_SOURCE_JPEG)}; + lardon3d_acquisition_ingest_test_set_metadata(same, 2); + Lardon3DAcquisitionIngestOutput paired{}; + Lardon3DAcquisitionIngestOptions jpeg_options = options(); + CHECK(lardon3d_acquisition_ingest(&strong.state, strong.scanset.scanset_id, pair, 2, + &jpeg_options, &paired) == LARDON3D_ACQUISITION_INGEST_OK); + CHECK(paired.group_count == 1 && paired.groups[0].basis == LARDON3D_ACQUISITION_GROUP_STRONG && + paired.groups[0].source_count == 2 && capture_count(strong.database, strong.scanset.scanset_id) == 1 && + capture_assets(strong.database, paired.groups[0].capture_id, 2)); + + /* A competing equal strong candidate remains entirely ungrouped. */ + Fixture ambiguous; + CHECK(ambiguous.open()); + char paths[3][4096]; + const char *physical_ids[] = {"physical-a", "physical-b", "physical-c"}; + for (size_t i = 0; i < 3; ++i) { + CHECK(ambiguous.path(paths[i], i == 0 ? "a.JPG" : i == 1 ? "b.JPG" : "c.JPG")); + CHECK(write_jpeg(paths[i], physical_ids[i])); + } + Lardon3DAcquisitionIngestSource triple[] = {{paths[0], 0}, {paths[1], 0}, {paths[2], 0}}; + Lardon3DAcquisitionMetadata tied[] = {metadata("TIE"), metadata("TIE"), metadata("TIE")}; + lardon3d_acquisition_ingest_test_set_metadata(tied, 3); + Lardon3DAcquisitionIngestOutput ambiguous_output{}; + CHECK(lardon3d_acquisition_ingest(&ambiguous.state, ambiguous.scanset.scanset_id, triple, 3, + &jpeg_options, &ambiguous_output) == LARDON3D_ACQUISITION_INGEST_OK); + CHECK(ambiguous_output.group_count == 3 && capture_count(ambiguous.database, ambiguous.scanset.scanset_id) == 3); + for (size_t i = 0; i < 3; ++i) CHECK(ambiguous_output.groups[i].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON); + + /* Resume is only the known Capture ID and converges image/source state. */ + Fixture resumed; + CHECK(resumed.open()); + char resume_file[4096]; CHECK(resumed.path(resume_file, "resume.JPG") && write_jpeg(resume_file, "resume")); + Lardon3DProjectDbCapture existing{}; + CHECK(lardon3d_project_db_create_capture(resumed.database, resumed.scanset.scanset_id, 7, &existing) == LARDON3D_PROJECT_DB_OK); + Lardon3DAcquisitionIngestSource one[] = {{resume_file, 0}}; + Lardon3DAcquisitionMetadata one_metadata[] = {metadata("RESUME")}; + Lardon3DAcquisitionIngestOptions resume_options = options(); resume_options.resume_capture_id = existing.capture_id; + lardon3d_acquisition_ingest_test_set_metadata(one_metadata, 1); + Lardon3DAcquisitionIngestOutput first{}; + CHECK(lardon3d_acquisition_ingest(&resumed.state, resumed.scanset.scanset_id, one, 1, &resume_options, &first) == LARDON3D_ACQUISITION_INGEST_OK && first.groups[0].capture_id == existing.capture_id); + Lardon3DAcquisitionIngestOutput retry{}; + CHECK(lardon3d_acquisition_ingest(&resumed.state, resumed.scanset.scanset_id, one, 1, &resume_options, &retry) == LARDON3D_ACQUISITION_INGEST_OK && capture_count(resumed.database, resumed.scanset.scanset_id) == 1 && capture_assets(resumed.database, existing.capture_id, 1)); + uint64_t images = 0; CHECK(lardon3d_project_db_count_images(resumed.database, resumed.scanset.scanset_id, &images) == LARDON3D_PROJECT_DB_OK && images == 1); + + fixture.close(); strong.close(); ambiguous.close(); resumed.close(); + return true; +} + +bool test_explicit_and_db_helper() { + Fixture fixture; + CHECK(fixture.open()); + char a[4096], b[4096], c[4096], helper_path[4096]; + CHECK(fixture.path(a, "a.JPG") && fixture.path(b, "b.JPG") && fixture.path(c, "c.JPG") && + fixture.path(helper_path, "helper.JPG") && write_jpeg(a, "a") && write_jpeg(b, "b") && + write_jpeg(c, "c") && write_jpeg(helper_path, "helper")); + Lardon3DAcquisitionIngestSource inputs[] = {{a, 9}, {b, 9}, {c, 10}}; + Lardon3DAcquisitionIngestOptions explicit_options = options(); + explicit_options.grouping = LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT; + Lardon3DAcquisitionIngestOutput output{}; + CHECK(lardon3d_acquisition_ingest(&fixture.state, fixture.scanset.scanset_id, inputs, 3, + &explicit_options, &output) == LARDON3D_ACQUISITION_INGEST_OK); + CHECK(output.group_count == 2 && output.groups[0].basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT && + output.groups[0].source_count == 2 && output.groups[1].basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT); + + Lardon3DProjectDbCapture capture{}, foreign{}; + CHECK(lardon3d_project_db_create_capture(fixture.database, fixture.scanset.scanset_id, 8, &capture) == LARDON3D_PROJECT_DB_OK && + lardon3d_project_db_create_capture(fixture.database, fixture.scanset.scanset_id, 9, &foreign) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbImageAsset asset{}; + CHECK(lardon3d_image_catalog_publish_asset_file(&fixture.state, helper_path, 8, UINT64_MAX, &asset) == LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED); + Lardon3DProjectDbImageRegisterStatus status{}; Lardon3DProjectDbImage image{}; + CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, capture.capture_id, asset.asset_id, + "helper.JPG", helper_path, 0, 8, false, &status, &image) == LARDON3D_PROJECT_DB_OK && status == LARDON3D_PROJECT_DB_IMAGE_REGISTERED); + uint64_t selected = 0; + CHECK(lardon3d_project_db_get_selected_capture_image(fixture.database, capture.capture_id, &selected) == LARDON3D_PROJECT_DB_NOT_FOUND); + CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, capture.capture_id, asset.asset_id, + "renamed-on-resume.JPG", "/relocated/source.JPG", 0, 99, true, &status, &image) == LARDON3D_PROJECT_DB_OK && status == LARDON3D_PROJECT_DB_IMAGE_ALREADY_PRESENT && + lardon3d_project_db_get_selected_capture_image(fixture.database, capture.capture_id, &selected) == LARDON3D_PROJECT_DB_OK && selected == image.image_id); + CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, foreign.capture_id, asset.asset_id, + "helper.JPG", helper_path, 0, 8, false, &status, &image) == LARDON3D_PROJECT_DB_CONSTRAINT); + fixture.close(); + return true; +} + +} // namespace + +int main() { + return test_ingest_grouping_and_resume() && test_explicit_and_db_helper() ? 0 : 1; +}