diff --git a/README.md b/README.md index cda5dc86..6fe69fda 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ CD-ROMs. ### Export And Author Media -- Export exact audio, rendered SFZ instruments (needs more work), individual +- Export selected Samples as mono or interleaved stereo WAV files, selected Wave + Data as mono WAV files, rendered SFZ instruments (needs more work), individual object packages, and dependency-complete volume packages. - Batch-export every volume in a partition as packages or as per-volume floppy sets. diff --git a/apps/application/CMakeLists.txt b/apps/application/CMakeLists.txt index 986d0e0a..648552a1 100644 --- a/apps/application/CMakeLists.txt +++ b/apps/application/CMakeLists.txt @@ -20,13 +20,17 @@ add_library(axk_application STATIC src/alteration_journal.cpp src/alteration_jou src/image_session_access.cpp src/image_session_allocation_map.cpp src/image_session_audition.cpp src/image_allocation_map.cpp src/image_session_floppy_set.cpp src/image_session_inspection.cpp + src/image_session_contracts.cpp src/image_session_operations.cpp src/image_session_program_generation.cpp + src/image_session_program_assignment_cleanup.cpp src/image_session_system_program_contexts.cpp src/image_session_object_reader.cpp src/image_session_open.cpp src/image_session_support.cpp src/image_sessions.cpp src/job_request_resources.cpp src/jobs.cpp src/midi_operations.cpp src/jobs_support.cpp - src/operation_registry.cpp src/package_filename.cpp src/package_operations.cpp + src/operation_registry.cpp src/operation_registry_names.cpp + src/operation_registry_program_assignments.cpp + src/package_filename.cpp src/package_operations.cpp src/package_operations_support.cpp src/session_package_import_plan.cpp src/session_package_operations.cpp src/package_plan_store.cpp src/path_reservations.cpp diff --git a/apps/application/include/axklib/application/image_session_contracts.hpp b/apps/application/include/axklib/application/image_session_contracts.hpp new file mode 100644 index 00000000..23bf50aa --- /dev/null +++ b/apps/application/include/axklib/application/image_session_contracts.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "axklib/application/contracts.hpp" +#include "axklib/application/image_sessions.hpp" + +namespace axk::app { + +[[nodiscard]] Result image_source_ref_from_json(const nlohmann::json &reference); +[[nodiscard]] nlohmann::json image_source_ref_json(const ImageSourceRef &source); +[[nodiscard]] nlohmann::json image_session_summary_json(const ImageSessionSummary &summary); + +} // namespace axk::app diff --git a/apps/application/include/axklib/application/image_session_operations.hpp b/apps/application/include/axklib/application/image_session_operations.hpp new file mode 100644 index 00000000..365429b1 --- /dev/null +++ b/apps/application/include/axklib/application/image_session_operations.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "axklib/application/contracts.hpp" + +namespace axk::app { + +class ImageSessionManager; +class OperationRegistry; + +[[nodiscard]] Result bind_image_session_operations(OperationRegistry ®istry, ImageSessionManager &images); + +} // namespace axk::app diff --git a/apps/application/include/axklib/application/image_sessions.hpp b/apps/application/include/axklib/application/image_sessions.hpp index 93385e4f..c099abab 100644 --- a/apps/application/include/axklib/application/image_sessions.hpp +++ b/apps/application/include/axklib/application/image_sessions.hpp @@ -349,6 +349,38 @@ struct ImageProgramGenerationPlan { axk::AlterationManifest manifest; }; +struct ImageProgramAssignmentCleanupCandidate { + std::string program_object_id; + std::uint8_t program_number{}; + std::string program_name; + std::uint8_t assignment_ordinal{}; + std::string assignment_name; + std::string target_object_type; + std::string receive_channel_display; + std::string reason; + std::size_t candidate_target_count{}; + bool default_selected{true}; +}; + +struct ImageProgramAssignmentCleanupInspection { + std::string image_id; + std::uint64_t revision{}; + std::string content_scope_id; + std::size_t total_candidate_count{}; + std::vector candidates; +}; + +struct ImageProgramAssignmentCleanupSelection { + std::string program_object_id; + std::uint8_t assignment_ordinal{}; +}; + +struct ImageProgramAssignmentCleanupPlan { + ImageProgramAssignmentCleanupInspection inspection; + std::vector selections; + axk::AlterationManifest manifest; +}; + struct ImageValidationItem { std::string code; std::string severity; @@ -430,7 +462,8 @@ class ImageSessionManager { ImageSessionManager &operator=(const ImageSessionManager &) = delete; [[nodiscard]] Result open(const ImageSourceRef &source, std::string owner_id, - const CancellationToken &cancellation = {}); + const CancellationToken &cancellation = {}, + ProgressSink *progress = nullptr); [[nodiscard]] Result attach_companions(std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, const CompanionSelection &selection, @@ -451,6 +484,13 @@ class ImageSessionManager { plan_program_generation(std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, std::string_view content_scope_id, const std::vector &selections); + [[nodiscard]] Result + inspect_program_assignment_cleanup(std::string_view image_id, std::string_view owner_id, + std::uint64_t expected_revision, std::string_view content_scope_id); + [[nodiscard]] Result + plan_program_assignment_cleanup(std::string_view image_id, std::string_view owner_id, + std::uint64_t expected_revision, std::string_view content_scope_id, + const std::vector &selections); [[nodiscard]] Result begin_read(std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision); [[nodiscard]] Result begin_mutation(std::string_view image_id, std::string_view owner_id, @@ -502,7 +542,7 @@ class ImageSessionManager { [[nodiscard]] Result open_with_companion_sources(const ImageSourceRef &source, std::string owner_id, const std::vector &companion_sources, - const CancellationToken &cancellation); + const CancellationToken &cancellation, ProgressSink *progress); struct Implementation; std::unique_ptr implementation_; }; diff --git a/apps/application/src/application_operations.cpp b/apps/application/src/application_operations.cpp index e4948f12..879c4638 100644 --- a/apps/application/src/application_operations.cpp +++ b/apps/application/src/application_operations.cpp @@ -5,6 +5,7 @@ #include "axklib/application/directory_archive_operations.hpp" #include "axklib/application/extraction_operations.hpp" #include "axklib/application/file_operations.hpp" +#include "axklib/application/image_session_operations.hpp" #include "axklib/application/midi_operations.hpp" #include "axklib/application/package_operations.hpp" #include "axklib/application/session_audio_export_operations.hpp" @@ -218,6 +219,8 @@ axk::app::make_application_registry(const Sandbox &sandbox, UploadStore &uploads axk::app::Result axk::app::bind_session_application_operations( OperationRegistry ®istry, const Sandbox &sandbox, UploadStore &uploads, ImageSessionManager &images, AlterationJournalStore &journals, DownloadArchiveStore &downloads, const axk::MediaBuildLimits &media_limits) { + if (auto bound = bind_image_session_operations(registry, images); !bound) + return bound; if (auto bound = bind_directory_archive_operations(registry, sandbox, downloads); !bound) return bound; if (auto bound = bind_audition_operations(registry, images); !bound) diff --git a/apps/application/src/image_session_contracts.cpp b/apps/application/src/image_session_contracts.cpp new file mode 100644 index 00000000..765d91af --- /dev/null +++ b/apps/application/src/image_session_contracts.cpp @@ -0,0 +1,84 @@ +#include "axklib/application/image_session_contracts.hpp" + +#include +#include +#include + +namespace { + +std::string_view floppy_set_status_name(axk::app::ImageFloppySetStatus status) { + switch (status) { + case axk::app::ImageFloppySetStatus::single: + return "SINGLE"; + case axk::app::ImageFloppySetStatus::incomplete: + return "INCOMPLETE"; + case axk::app::ImageFloppySetStatus::complete: + return "COMPLETE"; + case axk::app::ImageFloppySetStatus::recovery: + return "RECOVERY"; + } + return "SINGLE"; +} + +} // namespace + +axk::app::Result axk::app::image_source_ref_from_json(const nlohmann::json &reference) { + try { + const auto kind = reference.at("kind").get(); + if (kind == "FILE") { + const auto &file = reference.at("file"); + return ImageSourceRef{file.at("rootId").get(), file.at("relativePath").get(), + ImageSourceKind::file}; + } + if (kind == "AXK_OBJECT_DIRECTORY") { + const auto &directory = reference.at("directory"); + return ImageSourceRef{directory.at("rootId").get(), + directory.at("relativePath").get(), + ImageSourceKind::axk_object_directory}; + } + } catch (const nlohmann::json::exception &) { + return std::unexpected(Error{"invalid_request", "source must be one FILE or AXK_OBJECT_DIRECTORY reference"}); + } + return std::unexpected(Error{"invalid_request", "image source kind is unsupported"}); +} + +nlohmann::json axk::app::image_source_ref_json(const ImageSourceRef &source) { + if (source.kind == ImageSourceKind::file) { + return {{"kind", "FILE"}, {"file", {{"rootId", source.root_id}, {"relativePath", source.relative_path}}}}; + } + return {{"kind", "AXK_OBJECT_DIRECTORY"}, + {"directory", {{"rootId", source.root_id}, {"relativePath", source.relative_path}}}}; +} + +nlohmann::json axk::app::image_session_summary_json(const ImageSessionSummary &summary) { + nlohmann::json companion_sources = nlohmann::json::array(); + for (const auto &source : summary.companion_sources) + companion_sources.push_back(image_source_ref_json(source)); + nlohmann::json floppy_set; + if (summary.floppy_set) { + nlohmann::json members = nlohmann::json::array(); + for (const auto &member : summary.floppy_set->members) + members.push_back({{"index", member.index}, {"label", member.label}, {"marker", member.marker}}); + floppy_set = {{"status", floppy_set_status_name(summary.floppy_set->status)}, + {"setLabel", summary.floppy_set->set_label}, + {"members", std::move(members)}, + {"nextRequiredIndex", summary.floppy_set->next_required_index + ? nlohmann::json(*summary.floppy_set->next_required_index) + : nlohmann::json{}}}; + } + return {{"imageId", summary.image_id}, + {"revision", summary.revision}, + {"source", image_source_ref_json(summary.source)}, + {"companionSources", std::move(companion_sources)}, + {"floppySet", std::move(floppy_set)}, + {"format", summary.format}, + {"availableOperations", summary.available_operations}, + {"rootCount", summary.root_count}, + {"objectCount", summary.object_count}, + {"relationshipCount", summary.relationship_count}, + {"validation", + {{"valid", summary.validation.valid()}, + {"infoCount", summary.validation.info_count}, + {"warningCount", summary.validation.warning_count}, + {"errorCount", summary.validation.error_count}}}}; +} diff --git a/apps/application/src/image_session_inspection.cpp b/apps/application/src/image_session_inspection.cpp index b3eec4bd..bfeffc17 100644 --- a/apps/application/src/image_session_inspection.cpp +++ b/apps/application/src/image_session_inspection.cpp @@ -48,6 +48,8 @@ axk::app::Result axk::app::ImageSessionManager::i available_operations.emplace_back("images.deletion.orphans.inspect"); available_operations.emplace_back("images.programs.generate.inspect"); available_operations.emplace_back("images.programs.generate"); + available_operations.emplace_back("images.program_assignments.cleanup.inspect"); + available_operations.emplace_back("images.program_assignments.cleanup"); } return ImageSessionSummary{.image_id = (*session)->image_id, .revision = (*session)->revision, diff --git a/apps/application/src/image_session_open.cpp b/apps/application/src/image_session_open.cpp index 4cc5c87c..446f8622 100644 --- a/apps/application/src/image_session_open.cpp +++ b/apps/application/src/image_session_open.cpp @@ -13,6 +13,23 @@ namespace { +constexpr std::uint64_t image_open_progress_total = 5U; + +axk::app::Result report_image_open_progress(axk::ProgressSink *progress, + const axk::CancellationToken &cancellation, axk::ProgressPhase phase, + std::uint64_t completed, std::string label) { + if (const auto active = cancellation.check(); !active) + return std::unexpected(axk::app::Error{"operation_cancelled", active.error().message}); + if (progress != nullptr) { + progress->report({.phase = phase, + .completed = completed, + .total = image_open_progress_total, + .label = std::move(label), + .output_path = std::nullopt}); + } + return {}; +} + std::optional exact_dependency_size(const axk::ObjectSnapshot &root, const axk::RelationshipGraph &graph, const std::map> &objects, @@ -66,16 +83,20 @@ exact_dependency_size(const axk::ObjectSnapshot &root, const axk::RelationshipGr axk::app::Result axk::app::ImageSessionManager::open(const ImageSourceRef &source, std::string owner_id, - const CancellationToken &cancellation) { - return open_with_companion_sources(source, std::move(owner_id), {}, cancellation); + const CancellationToken &cancellation, ProgressSink *progress) { + return open_with_companion_sources(source, std::move(owner_id), {}, cancellation, progress); } -axk::app::Result -axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef &source, std::string owner_id, - const std::vector &companion_sources, - const CancellationToken &cancellation) { +axk::app::Result axk::app::ImageSessionManager::open_with_companion_sources( + const ImageSourceRef &source, std::string owner_id, const std::vector &companion_sources, + const CancellationToken &cancellation, ProgressSink *progress) { if (owner_id.empty()) return std::unexpected(session_error("invalid_owner", "image session owner is required")); + if (auto reported = + report_image_open_progress(progress, cancellation, ProgressPhase::opening, 0U, "Opening image source"); + !reported) { + return std::unexpected(reported.error()); + } auto admission = implementation_->reserve_session(); if (!admission) return std::unexpected(admission.error()); @@ -212,12 +233,22 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef .marker = "NONE"}); } } + if (auto reported = + report_image_open_progress(progress, cancellation, ProgressPhase::reading, 1U, "Reading image metadata"); + !reported) { + return std::unexpected(reported.error()); + } const auto inventory_mode = media->kind() == axk::MediaKind::fat12_floppy_set ? axk::MediaObjectReadMode::complete : axk::MediaObjectReadMode::decoded_metadata; auto inventory = axk::build_media_inventory(*media, inventory_mode, 64U * 1024U * 1024U, cancellation); if (!inventory) return std::unexpected(core_error(inventory.error(), source)); + if (auto reported = report_image_open_progress(progress, cancellation, ProgressPhase::resolving, 2U, + "Resolving sampler objects"); + !reported) { + return std::unexpected(reported.error()); + } auto graph = axk::build_relationship_graph(inventory->catalog); auto tree = axk::build_content_tree(*media, inventory->catalog, graph); std::unordered_map partition_names; @@ -472,6 +503,12 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef return std::unexpected(appended.error()); } + if (auto reported = report_image_open_progress(progress, cancellation, ProgressPhase::validating, 3U, + "Validating image contents"); + !reported) { + return std::unexpected(reported.error()); + } + const auto append_validation = [&](std::string code, std::string severity, std::string message, std::string sampler_path, std::optional object_id) { session->validation.push_back( @@ -512,6 +549,12 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef session_error("image_source_changed", "image source changed while the session was opened", true)); } + if (auto reported = report_image_open_progress(progress, cancellation, ProgressPhase::publishing, 4U, + "Publishing image session"); + !reported) { + return std::unexpected(reported.error()); + } + do { auto image_id = random_identifier("image-"); if (!image_id) @@ -520,7 +563,17 @@ axk::app::ImageSessionManager::open_with_companion_sources(const ImageSourceRef if ((*admission)->promote(session)) break; } while (true); - return inspect(session->image_id, session->owner_id); + auto summary = inspect(session->image_id, session->owner_id); + if (!summary) + return std::unexpected(summary.error()); + if (progress != nullptr) { + progress->report({.phase = ProgressPhase::publishing, + .completed = image_open_progress_total, + .total = image_open_progress_total, + .label = "Image session ready", + .output_path = std::nullopt}); + } + return summary; } axk::app::Result @@ -606,7 +659,8 @@ axk::app::ImageSessionManager::attach_companions(std::string_view image_id, std: implementation_->idle_retention, implementation_->clock, implementation_->path_reservations}; - auto opened = refreshed.open_with_companion_sources(source, std::string{owner_id}, unique_candidates, cancellation); + auto opened = + refreshed.open_with_companion_sources(source, std::string{owner_id}, unique_candidates, cancellation, nullptr); if (!opened) return std::unexpected(opened.error()); if (opened->companion_sources.empty()) { diff --git a/apps/application/src/image_session_operations.cpp b/apps/application/src/image_session_operations.cpp new file mode 100644 index 00000000..0b9a6b0b --- /dev/null +++ b/apps/application/src/image_session_operations.cpp @@ -0,0 +1,23 @@ +#include "axklib/application/image_session_operations.hpp" + +#include "axklib/application/image_session_contracts.hpp" +#include "axklib/application/image_sessions.hpp" +#include "axklib/application/operation_registry.hpp" + +axk::app::Result axk::app::bind_image_session_operations(OperationRegistry ®istry, + ImageSessionManager &images) { + return registry.bind( + "images.open", + [&images](const nlohmann::json &input, const OperationContext &context) -> Result { + const auto source = input.find("source"); + if (source == input.end()) + return std::unexpected(Error{"invalid_request", "image source is required"}); + auto parsed_source = image_source_ref_from_json(*source); + if (!parsed_source) + return std::unexpected(parsed_source.error()); + auto opened = images.open(*parsed_source, context.owner_id, context.cancellation, context.progress); + if (!opened) + return std::unexpected(opened.error()); + return image_session_summary_json(*opened); + }); +} diff --git a/apps/application/src/image_session_program_assignment_cleanup.cpp b/apps/application/src/image_session_program_assignment_cleanup.cpp new file mode 100644 index 00000000..4536203f --- /dev/null +++ b/apps/application/src/image_session_program_assignment_cleanup.cpp @@ -0,0 +1,178 @@ +#include "image_sessions_internal.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using axk::app::ImageProgramAssignmentCleanupCandidate; +using axk::app::ImageProgramAssignmentCleanupInspection; +using axk::app::ImageProgramAssignmentCleanupSelection; + +std::optional program_number(const axk::app::ImageObjectItem &item) { + const auto &value = item.entry_name.empty() ? item.name : item.entry_name; + unsigned int number{}; + const auto converted = std::from_chars(value.data(), value.data() + value.size(), number); + if (converted.ec != std::errc{} || converted.ptr != value.data() + value.size() || number < 1U || number > 128U) + return std::nullopt; + return static_cast(number); +} + +std::string target_type(const axk::app::ImageRelationshipItem &relationship) { + return relationship.type == "PROG_ASSIGNMENT_TO_SBAC" ? "SBAC" : "SBNK"; +} + +std::string unresolved_reason(const axk::app::ImageRelationshipItem &relationship) { + if (relationship.basis.ends_with("-nonlocal")) + return "NONLOCAL_TARGET"; + return relationship.candidate_object_ids.size() > 1U ? "AMBIGUOUS_TARGET" : "MISSING_TARGET"; +} + +template +axk::app::Result +inspect_session(const Session &session, std::uint64_t expected_revision, std::string_view content_scope_id) { + if (session.revision != expected_revision) + return std::unexpected(session_error("image_revision_stale", "image session revision changed", true)); + if (session.format != "sfs" || !session.media) { + return std::unexpected( + session_error("image_mutation_unsupported", "Program assignment cleanup requires an SFS image session")); + } + const auto content_item = std::ranges::find(session.content, content_scope_id, &axk::app::ImageContentItem::id); + if (content_item == session.content.end()) + return std::unexpected(session_error("content_not_found", "content scope does not exist")); + if (content_item->kind != "volume" || !content_item->partition_index) { + return std::unexpected(session_error("content_scope_invalid", "Program assignment cleanup requires a volume")); + } + const auto scoped = session.object_indices_by_content_scope.find(std::string{content_scope_id}); + if (scoped == session.object_indices_by_content_scope.end()) + return std::unexpected(session_error("content_not_found", "content scope does not exist")); + + ImageProgramAssignmentCleanupInspection result{.image_id = session.image_id, + .revision = expected_revision, + .content_scope_id = std::string{content_scope_id}, + .total_candidate_count = 0U, + .candidates = {}}; + std::map> programs; + for (const auto index : scoped->second) { + if (index >= session.objects.size()) + return std::unexpected( + session_error("image_session_invalid", "content scope references an invalid object")); + const auto &object = session.objects[index]; + if (object.type == "PROG") + programs.emplace(object.id, &object); + } + for (const auto &relationship : session.relationships) { + const auto program = programs.find(relationship.source_object_id); + if (program == programs.end() || relationship.assignment_state != "stored-assignment" || + relationship.target_object_id || !relationship.assignment_index || + (relationship.type != "PROG_ASSIGNMENT_TO_SBAC" && relationship.type != "PROG_ASSIGNMENT_TO_SBNK")) { + continue; + } + const auto number = program_number(*program->second); + const auto snapshot = session.snapshots_by_id.find(program->first); + const auto *decoded = snapshot == session.snapshots_by_id.end() + ? nullptr + : std::get_if(&snapshot->second.object.payload); + if (!number || decoded == nullptr || *relationship.assignment_index >= axk::maximum_program_assignments) + continue; + result.candidates.push_back({.program_object_id = program->first, + .program_number = *number, + .program_name = decoded->program_name, + .assignment_ordinal = static_cast(*relationship.assignment_index), + .assignment_name = relationship.assignment_name, + .target_object_type = target_type(relationship), + .receive_channel_display = relationship.receive_channel_display, + .reason = unresolved_reason(relationship), + .candidate_target_count = relationship.candidate_object_ids.size(), + .default_selected = true}); + } + std::ranges::sort(result.candidates, [](const auto &left, const auto &right) { + return std::pair{left.program_number, left.assignment_ordinal} < + std::pair{right.program_number, right.assignment_ordinal}; + }); + result.total_candidate_count = result.candidates.size(); + return result; +} + +std::pair selection_key(const ImageProgramAssignmentCleanupSelection &selection) { + return {selection.program_object_id, selection.assignment_ordinal}; +} + +} // namespace + +axk::app::Result +axk::app::ImageSessionManager::inspect_program_assignment_cleanup(std::string_view image_id, std::string_view owner_id, + std::uint64_t expected_revision, + std::string_view content_scope_id) { + const auto session = implementation_->owned(image_id, owner_id); + if (!session) + return std::unexpected(session.error()); + const std::scoped_lock access{(*session)->access_mutex}; + return inspect_session(**session, expected_revision, content_scope_id); +} + +axk::app::Result +axk::app::ImageSessionManager::plan_program_assignment_cleanup( + std::string_view image_id, std::string_view owner_id, std::uint64_t expected_revision, + std::string_view content_scope_id, const std::vector &selections) { + const auto session = implementation_->owned(image_id, owner_id); + if (!session) + return std::unexpected(session.error()); + const std::scoped_lock access{(*session)->access_mutex}; + auto inspection = inspect_session(**session, expected_revision, content_scope_id); + if (!inspection) + return std::unexpected(inspection.error()); + const auto content_item = std::ranges::find((*session)->content, content_scope_id, &ImageContentItem::id); + if (content_item == (*session)->content.end() || !content_item->partition_index) + return std::unexpected(session_error("content_scope_invalid", "Program assignment cleanup requires a volume")); + if (selections.empty()) { + return std::unexpected( + session_error("program_assignment_cleanup_invalid", "select at least one assignment to clean")); + } + + std::map, const ImageProgramAssignmentCleanupCandidate *> candidates; + for (const auto &candidate : inspection->candidates) + candidates.emplace(std::pair{std::string_view{candidate.program_object_id}, candidate.assignment_ordinal}, + &candidate); + std::set> selected; + for (const auto &selection : selections) { + if (!selected.emplace(selection.program_object_id, selection.assignment_ordinal).second) { + return std::unexpected(session_error("program_assignment_cleanup_invalid", + "Program assignment cleanup selections must be unique")); + } + if (!candidates.contains(selection_key(selection))) { + return std::unexpected(session_error("program_assignment_cleanup_stale", + "a selected assignment is no longer unresolved", true)); + } + } + + axk::AlterationManifest manifest{std::string{axk::alteration_manifest_schema_version}, {}}; + std::vector ordered; + for (const auto &candidate : inspection->candidates) { + if (!selected.contains({candidate.program_object_id, candidate.assignment_ordinal})) + continue; + ordered.push_back({candidate.program_object_id, candidate.assignment_ordinal}); + auto operation = std::ranges::find_if(manifest.operations, [&](const axk::AlterationOperation &row) { + const auto *cleanup = std::get_if(&row.data); + return cleanup != nullptr && cleanup->program_number == candidate.program_number; + }); + if (operation == manifest.operations.end()) { + manifest.operations.push_back( + {std::format("clean-program-{:03}", candidate.program_number), + axk::ClearProgramAssignmentsOperation{axk::PartitionIndex{*content_item->partition_index}, + content_item->name, + candidate.program_number, + {candidate.assignment_ordinal}}}); + } else { + std::get(operation->data) + .assignment_ordinals.push_back(candidate.assignment_ordinal); + } + } + return ImageProgramAssignmentCleanupPlan{std::move(*inspection), std::move(ordered), std::move(manifest)}; +} diff --git a/apps/application/src/operation_registry.cpp b/apps/application/src/operation_registry.cpp index e3e96b76..d2645cf4 100644 --- a/apps/application/src/operation_registry.cpp +++ b/apps/application/src/operation_registry.cpp @@ -6,13 +6,14 @@ #include "axklib/application/system_service.hpp" +#include "operation_registry_program_assignments.hpp" + namespace { using axk::app::ExecutionMode; using axk::app::HttpMethod; using axk::app::OperationClass; using axk::app::OperationDescriptor; - const std::array descriptors{ OperationDescriptor{"system.version", "axklib --version", @@ -22,6 +23,17 @@ const std::array descriptors{ {}, "EmptyRequest", "VersionResponse"}, + OperationDescriptor{"images.open", + {}, + HttpMethod::post, + "/api/v1/images", + ExecutionMode::job, + {}, + "ImageOpenRequest", + "ImageSession", + OperationClass::read, + false, + false}, OperationDescriptor{"report.info", "axklib info", HttpMethod::post, @@ -613,7 +625,6 @@ axk::app::OperationRegistry::invoke(std::string_view operation_id, const Json &i } return operation.handler(input, context); } - axk::app::Result> axk::app::OperationRegistry::path_accesses(std::string_view operation_id, const Json &input, const OperationContext &context) const { @@ -625,7 +636,6 @@ axk::app::OperationRegistry::path_accesses(std::string_view operation_id, const return std::vector{}; return operation.path_access_resolver(input, context); } - std::vector axk::app::OperationRegistry::entries() const { std::vector result; result.reserve(operations_.size()); @@ -650,37 +660,11 @@ axk::app::OperationRegistry axk::app::make_operation_registry() { if (!registry.declare(descriptor)) std::terminate(); } + for (const auto &descriptor : program_assignment_cleanup_descriptors()) { + if (!registry.declare(descriptor)) + std::terminate(); + } if (!registry.bind_typed("system.version", system_version)) std::terminate(); return registry; } - -std::string_view axk::app::http_method_name(HttpMethod method) noexcept { - switch (method) { - case HttpMethod::get: - return "GET"; - case HttpMethod::post: - return "POST"; - } - return "POST"; -} - -std::string_view axk::app::execution_mode_name(ExecutionMode mode) noexcept { - switch (mode) { - case ExecutionMode::request: - return "request"; - case ExecutionMode::job: - return "job"; - } - return "request"; -} - -std::string_view axk::app::operation_class_name(OperationClass operation_class) noexcept { - switch (operation_class) { - case OperationClass::read: - return "read"; - case OperationClass::write: - return "write"; - } - return "read"; -} diff --git a/apps/application/src/operation_registry_names.cpp b/apps/application/src/operation_registry_names.cpp new file mode 100644 index 00000000..627aec5a --- /dev/null +++ b/apps/application/src/operation_registry_names.cpp @@ -0,0 +1,31 @@ +#include "axklib/application/operation_registry.hpp" + +std::string_view axk::app::http_method_name(HttpMethod method) noexcept { + switch (method) { + case HttpMethod::get: + return "GET"; + case HttpMethod::post: + return "POST"; + } + return "POST"; +} + +std::string_view axk::app::execution_mode_name(ExecutionMode mode) noexcept { + switch (mode) { + case ExecutionMode::request: + return "request"; + case ExecutionMode::job: + return "job"; + } + return "request"; +} + +std::string_view axk::app::operation_class_name(OperationClass operation_class) noexcept { + switch (operation_class) { + case OperationClass::read: + return "read"; + case OperationClass::write: + return "write"; + } + return "read"; +} diff --git a/apps/application/src/operation_registry_program_assignments.cpp b/apps/application/src/operation_registry_program_assignments.cpp new file mode 100644 index 00000000..5b36f482 --- /dev/null +++ b/apps/application/src/operation_registry_program_assignments.cpp @@ -0,0 +1,33 @@ +#include "operation_registry_program_assignments.hpp" + +namespace axk::app { + +const std::array &program_assignment_cleanup_descriptors() { + static const std::array descriptors{ + OperationDescriptor{"images.program_assignments.cleanup.inspect", + {}, + HttpMethod::post, + "/api/v1/image-program-assignment-cleanup-inspections", + ExecutionMode::request, + {}, + "ImageProgramAssignmentCleanupInspectionRequest", + "ImageProgramAssignmentCleanupInspection", + OperationClass::read, + false, + false}, + OperationDescriptor{"images.program_assignments.cleanup", + {}, + HttpMethod::post, + "/api/v1/image-program-assignment-cleanups", + ExecutionMode::job, + {}, + "ImageProgramAssignmentCleanupRequest", + "ImageProgramAssignmentCleanupResult", + OperationClass::write, + true, + false}, + }; + return descriptors; +} + +} // namespace axk::app diff --git a/apps/application/src/operation_registry_program_assignments.hpp b/apps/application/src/operation_registry_program_assignments.hpp new file mode 100644 index 00000000..654fe148 --- /dev/null +++ b/apps/application/src/operation_registry_program_assignments.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "axklib/application/operation_registry.hpp" + +namespace axk::app { + +const std::array &program_assignment_cleanup_descriptors(); + +} // namespace axk::app diff --git a/apps/application/src/session_audio_export_operations.cpp b/apps/application/src/session_audio_export_operations.cpp index dfb31a52..80c0fb06 100644 --- a/apps/application/src/session_audio_export_operations.cpp +++ b/apps/application/src/session_audio_export_operations.cpp @@ -38,12 +38,16 @@ struct AudioExportRoot { std::string object_key; }; +enum class AudioExportSelectionMode : std::uint8_t { dependency_closure, selected_audio_objects }; + struct AudioExportSelection { std::set> volumes; axk::app::ExactExportClosure closure; std::vector issues; std::string default_directory_name; std::size_t sfz_file_count{}; + std::size_t wav_file_count{}; + std::optional selected_wav; [[nodiscard]] bool sfz_eligible() const noexcept { return sfz_file_count != 0U && @@ -116,6 +120,16 @@ axk::app::Result> parse_session_identity(c } } +axk::app::Result parse_selection_mode(const Json &input) { + const auto value = input.value("selectionMode", std::string{}); + if (value == "DEPENDENCY_CLOSURE") + return AudioExportSelectionMode::dependency_closure; + if (value == "SELECTED_AUDIO_OBJECTS") + return AudioExportSelectionMode::selected_audio_objects; + return std::unexpected( + operation_error("invalid_request", "selectionMode must be DEPENDENCY_CLOSURE or SELECTED_AUDIO_OBJECTS")); +} + axk::app::Result> parse_roots(const Json &input, const std::unordered_map &object_keys_by_id, const std::unordered_map &volume_scopes_by_id) { @@ -190,9 +204,40 @@ void insert_object(AudioExportSelection &selection, const axk::ObjectSnapshot &o selection.closure.wave_data.insert(object.key); } +enum class SelectedSampleMembershipStatus : std::uint8_t { missing, exact, invalid }; + +SelectedSampleMembershipStatus selected_sample_membership_status(const axk::RelationshipGraph &graph, + std::string_view sample_key) { + std::map> members; + bool conflicting_role{}; + for (const auto &relationship : graph.relationships) { + if (relationship.source_key != sample_key || !relationship.target_key || + relationship.quality != axk::RelationshipQuality::known || + (relationship.type != "SBNK_LEFT_MEMBER_TO_SMPL" && relationship.type != "SBNK_RIGHT_MEMBER_TO_SMPL")) { + continue; + } + const auto role = relationship.type == "SBNK_LEFT_MEMBER_TO_SMPL" ? "left" : "right"; + const auto [found, inserted] = members.emplace(role, *relationship.target_key); + if (!inserted && found->second != *relationship.target_key) + conflicting_role = true; + } + if (members.empty()) + return SelectedSampleMembershipStatus::missing; + if (conflicting_role) + return SelectedSampleMembershipStatus::invalid; + if (members.size() == 1U) + return SelectedSampleMembershipStatus::exact; + if (members.size() == 2U && members.contains("left") && members.contains("right") && + members.at("left") != members.at("right")) { + return SelectedSampleMembershipStatus::exact; + } + return SelectedSampleMembershipStatus::invalid; +} + axk::app::Result resolve_selection(const std::vector &roots, const axk::ObjectCatalog &catalog, - const axk::RelationshipGraph &graph) { + const axk::RelationshipGraph &graph, + AudioExportSelectionMode mode) { std::unordered_map by_key; by_key.reserve(catalog.objects.size()); for (const auto &object : catalog.objects) @@ -200,7 +245,9 @@ axk::app::Result resolve_selection(const std::vector