Playback Mode Pages
ci / checks (push) Successful in 50m29s

This commit is contained in:
khodowany
2026-10-06 23:11:25 -05:00
parent 125aaa1637
commit d2f493c17b
33 changed files with 620 additions and 130 deletions
+110 -31
View File
@@ -1,5 +1,6 @@
#include <algorithm>
#include <cstdint>
#include <cmath>
#include <expected>
#include <filesystem>
#include <string>
@@ -141,42 +142,120 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
} catch (...) {
return invalid("missing performance timing; regenerate score assets");
}
auto cursor = std::size_t{};
for (auto const &item : *pages) {
if (!item.is_object()) {
return invalid("page must be an object");
try {
for (auto const &time : times) {
document.measure_boundaries.push_back({numerator(time).convert_to<std::uint64_t>(), denominator(time).convert_to<std::uint64_t>()});
}
auto path = string_field(item.as_object(), "path");
auto hash = string_field(item.as_object(), "sha256");
if (!path || !hash) {
return invalid("page requires path and sha256 strings");
auto const *playback = array_field(*presentation, "playback_pages");
if (!playback || playback->empty() || playback->size() > 10000) {
return invalid("missing playback pages; regenerate score assets");
}
auto relative = std::filesystem::path{*path};
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
relative.is_absolute() || relative.extension() != ".png" ||
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
return invalid("page paths must be safe relative PNG paths");
}
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
})) {
return invalid("page sha256 must be 64 lowercase hexadecimal digits");
}
try {
auto first = item.at("first_measure").to_number<std::size_t>();
auto count = item.at("measure_count").to_number<std::size_t>();
auto start = vamp_zero::parse_beat(item.at("start_beat")), end = vamp_zero::parse_beat(item.at("end_beat"));
if (first != cursor + 1 || count == 0 || count > measures->size() - cursor || !start || !end || rational(*start) != times[cursor] || rational(*end) != times[cursor + count]) {
auto parse_page = [&](boost::json::value const &item) -> score_page {
if (!item.is_object()) {
throw std::invalid_argument("page must be an object");
}
auto path = string_field(item.as_object(), "path");
auto hash = string_field(item.as_object(), "sha256");
if (!path || !hash) {
throw std::invalid_argument("page requires path and sha256 strings");
}
auto relative = std::filesystem::path{*path};
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
relative.is_absolute() || relative.extension() != ".png" ||
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
throw std::invalid_argument("page paths must be safe relative PNG paths");
}
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
})) {
throw std::invalid_argument("page sha256 must be 64 lowercase hexadecimal digits");
}
auto parse_range = [&](boost::json::value const &value, auto &range) {
range.first_measure = value.at("first_measure").to_number<std::size_t>();
range.measure_count = value.at("measure_count").to_number<std::size_t>();
auto a = range.first_measure, count = range.measure_count;
auto start = parse_beat(value.at("start_beat")), end = parse_beat(value.at("end_beat"));
if (!a || a > measures->size() || !count || count > measures->size() - (a - 1) || !start || !end || rational(*start) != times[a - 1] || rational(*end) != times[a + count - 1]) {
throw std::invalid_argument("invalid page/system timing or coverage");
}
range.start_beat = *start;
range.end_beat = *end;
};
auto result = score_page{};
result.path = std::move(*path);
result.sha256 = std::move(*hash);
parse_range(item, result);
auto const &systems = item.at("systems").as_array();
if (systems.empty() || systems.size() > 2) {
throw std::invalid_argument("invalid page systems");
}
auto cursor = result.first_measure;
for (auto const &value : systems) {
auto system = score_page::system{};
parse_range(value, system);
if (system.first_measure != cursor) {
throw std::invalid_argument("system gap or overlap");
}
auto const &labels = value.at("labels").as_array();
if (labels.size() != system.measure_count) {
throw std::invalid_argument("invalid measure label coverage");
}
for (auto const &label : labels) {
auto parsed = score_page::measure_label{};
parsed.measure = label.at("measure").to_number<std::size_t>();
auto const &bounds = label.at("bounds").as_array();
if (parsed.measure != cursor++ || bounds.size() != 4) {
throw std::invalid_argument("invalid measure label");
}
for (std::size_t k = 0; k < 4; ++k) {
parsed.bounds[k] = bounds[k].to_number<double>();
if (!std::isfinite(parsed.bounds[k])) {
throw std::invalid_argument("non-finite label bounds");
}
}
auto const &r = parsed.bounds;
if (!(4 <= r[0] && r[0] < r[2] && r[2] <= 972 && 4 <= r[1] && r[1] < r[3] && r[3] <= 480)) {
throw std::invalid_argument("invalid label bounds");
}
system.labels.push_back(parsed);
}
result.systems.push_back(std::move(system));
}
if (cursor != result.first_measure + result.measure_count) {
throw std::invalid_argument("systems disagree with page coverage");
}
return result;
};
auto cursor = std::size_t{1};
auto canonical = std::vector<score_page::system>{};
for (auto const &item : *pages) {
auto page = parse_page(item);
if (page.first_measure != cursor || (page.systems.size() != 2 && &item != &pages->back())) {
return invalid("invalid page measure coverage");
}
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end});
cursor += count;
} catch (...) {
return invalid("missing page timing; regenerate score assets");
cursor += page.measure_count;
canonical.insert(canonical.end(), page.systems.begin(), page.systems.end());
document.pages.push_back(std::move(page));
}
}
if (cursor != measures->size()) {
return invalid("incomplete page measure coverage");
if (cursor != measures->size() + 1 || playback->size() != canonical.size()) {
return invalid("incomplete normal or playback coverage");
}
for (std::size_t i = 0; i < playback->size(); ++i) {
auto page = parse_page((*playback)[i]);
if (page.systems.size() != std::min(std::size_t{2}, canonical.size() - i)) {
return invalid("invalid playback system pairing");
}
for (std::size_t j = 0; j < page.systems.size(); ++j) {
auto const &a = canonical[i + j];
auto const &b = page.systems[j];
if (a.first_measure != b.first_measure || a.measure_count != b.measure_count) {
return invalid("playback changes system boundaries");
}
}
document.playback_pages.push_back(std::move(page));
}
} catch (std::exception const &error) {
return invalid(std::string{"invalid playback presentation: "} + error.what() + "; regenerate score assets");
}
document.id = std::filesystem::path{source_path}.stem().string();
document.source_path = std::move(source_path);