Export layer collections through paint renderer

This commit is contained in:
2026-06-05 20:48:16 +02:00
parent 77268a28fb
commit 3c36be4b43
10 changed files with 815 additions and 21 deletions

View File

@@ -258,7 +258,9 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.p
pure six-face PNG export bytes encoded through `pp_assets`. The compositor pure six-face PNG export bytes encoded through `pp_assets`. The compositor
tests now also cover equirectangular PNG export from that same composited tests now also cover equirectangular PNG export from that same composited
document frame using the shader-equivalent cube sampling policy and document frame using the shader-equivalent cube sampling policy and
`pp_assets` PNG encoding. `pp_assets` PNG encoding, independent layer equirectangular PNG export for
layer collections, and merged animation-frame equirectangular PNG export for
frame collections.
- `pano_cli simulate-document-export` exposes the same pure document-to-PPI - `pano_cli simulate-document-export` exposes the same pure document-to-PPI
export, asset-level decode, and document reimport path through JSON export, asset-level decode, and document reimport path through JSON
automation and is covered by `pano_cli_simulate_document_export_smoke`. automation and is covered by `pano_cli_simulate_document_export_smoke`.
@@ -289,7 +291,10 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.p
falls back to `Canvas::export_cube_faces` if snapshot capture, PNG generation, falls back to `Canvas::export_cube_faces` if snapshot capture, PNG generation,
or file writing fails. PNG equirectangular export writes a pure or file writing fails. PNG equirectangular export writes a pure
document/paint-renderer equirectangular PNG before falling back to the document/paint-renderer equirectangular PNG before falling back to the
retained writer; JPEG/XMP equirectangular export, layer, animation-frame, retained writer. Payload-complete layer and animation-frame PNG collections
write pure document/paint-renderer equirectangular PNG sequences through the
app-core collection write/publish executor before retained fallback; JPEG/XMP
equirectangular export, Web handoff, incomplete-readback collection cases,
depth, and video export remain on retained writer paths. depth, and video export remain on retained writer paths.
- `pano_cli save-document-project` writes that pure document export to a PPI - `pano_cli save-document-project` writes that pure document export to a PPI
file and is covered by `pano_cli_save_document_project_roundtrip_smoke`, file and is covered by `pano_cli_save_document_project_roundtrip_smoke`,
@@ -1134,8 +1139,11 @@ powershell -ExecutionPolicy Bypass -File scripts\automation\apple-remote-build.p
app-core planned legacy work-directory face paths through the app-core app-core planned legacy work-directory face paths through the app-core
write/publish executor before falling back to the retained writer, PNG write/publish executor before falling back to the retained writer, PNG
equirectangular export writes the pure document/paint-renderer equirectangular export writes the pure document/paint-renderer
equirectangular PNG before retained fallback, and JPEG/XMP, collection, and equirectangular PNG before retained fallback, payload-complete layer and
depth exports remain on older retained paths. It animation-frame collections write pure document/paint-renderer PNG sequences
through the app-core collection write/publish executor, and JPEG/XMP,
Web/incomplete-readback collection cases, and depth exports remain on older
retained paths. It
also bridges timelapse and animation MP4 export picker-selected paths while also bridges timelapse and animation MP4 export picker-selected paths while
preserving desktop worker-thread timelapse behavior, mobile/Web save preserving desktop worker-thread timelapse behavior, mobile/Web save
callbacks, `App::rec_export`, animation `Canvas::export_anim_mp4`, and callbacks, `App::rec_export`, animation `Canvas::export_anim_mp4`, and

View File

@@ -24,8 +24,8 @@ and validation command.
| Capability | Current Area | Target Owner | Required Tests | | Capability | Current Area | Target Owner | Required Tests |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| PNG/JPEG import | `Image`, `Canvas` import paths | `pp_assets`, `pp_document` | Fixture import, malformed file | | PNG/JPEG import | `Image`, `Canvas` import paths | `pp_assets`, `pp_document` | Fixture import, malformed file |
| PNG/JPEG export | `Canvas`, `Image`, export dialogs | `pp_assets`, `pp_paint_renderer`, `pp_app_core` | Golden output tolerance, export start/target planning tests, live export-adapter document snapshot readiness through the shared paint-renderer export report, pure cube-face PNG writer, pure equirectangular PNG writer, JPEG/XMP parity still retained | | PNG/JPEG export | `Canvas`, `Image`, export dialogs | `pp_assets`, `pp_paint_renderer`, `pp_app_core` | Golden output tolerance, export start/target planning tests, live export-adapter document snapshot readiness through the shared paint-renderer export report, pure cube-face PNG writer, pure equirectangular PNG writer, pure layer/frame collection PNG writers, app-core collection write executor, JPEG/XMP parity still retained |
| Equirectangular import/export | `Canvas`, shaders, RTT, export dialogs | `pp_paint_renderer`, `pp_app_core` | Tiny cube/equirect golden, app-core file target tests, live export-adapter renderer-upload/face-PNG readiness report, pure document-frame equirectangular PNG export and live PNG writer fallback, JPEG/XMP retained until metadata parity is owned | | Equirectangular import/export | `Canvas`, shaders, RTT, export dialogs | `pp_paint_renderer`, `pp_app_core` | Tiny cube/equirect golden, app-core file target tests, live export-adapter renderer-upload/face-PNG readiness report, pure document-frame equirectangular PNG export and live PNG writer fallback, pure layer/frame equirectangular PNG collection export, JPEG/XMP retained until metadata parity is owned |
| Cube face export | `Canvas` fallback | `pp_paint_renderer`, `pp_app_core` | Pure six-face document frame composite, renderer texture-upload bridge, shared export-readiness report, app-core face filename planning and write/publish service execution, payload-complete canvas-snapshot renderer-upload and face-PNG automation, live document/renderer face-PNG writer with retained Canvas fallback, OpenGL command-plan coverage, six-face golden set | | Cube face export | `Canvas` fallback | `pp_paint_renderer`, `pp_app_core` | Pure six-face document frame composite, renderer texture-upload bridge, shared export-readiness report, app-core face filename planning and write/publish service execution, payload-complete canvas-snapshot renderer-upload and face-PNG automation, live document/renderer face-PNG writer with retained Canvas fallback, OpenGL command-plan coverage, six-face golden set |
| Depth export | `Canvas`, grid tools | `pp_paint_renderer` | Float/readback validation | | Depth export | `Canvas`, grid tools | `pp_paint_renderer` | Float/readback validation |

File diff suppressed because one or more lines are too long

View File

@@ -692,9 +692,12 @@ executor using the app-core-planned legacy face filenames when available and
falls back to retained `Canvas::export_cube_faces` on snapshot/write failure. falls back to retained `Canvas::export_cube_faces` on snapshot/write failure.
PNG equirectangular export now uses the same document/composite payload to PNG equirectangular export now uses the same document/composite payload to
generate an equirectangular PNG through `pp_paint_renderer` before the retained generate an equirectangular PNG through `pp_paint_renderer` before the retained
fallback. JPEG/XMP equirectangular export, layer collections, animation-frame fallback. Payload-complete layer and animation-frame PNG collections now use
collections, depth, and video still delegate to retained `Canvas` writers after pure `pp_paint_renderer` equirectangular PNG generation plus app-core
readiness reporting. collection write/publish execution before retained fallback. JPEG/XMP
equirectangular export, Web handoff, depth, video, and incomplete-readback
collection cases still delegate to retained `Canvas` writers after readiness
reporting.
`pano_cli plan-image-import` exposes app-core planning for File > Import image `pano_cli plan-image-import` exposes app-core planning for File > Import image
route decisions, including wide equirectangular images, legacy vertical cube route decisions, including wide equirectangular images, legacy vertical cube
strips, regular transform-placement images, and invalid image dimensions; live strips, regular transform-placement images, and invalid image dimensions; live
@@ -2536,10 +2539,13 @@ Results:
write/publish service executor before falling back to retained `Canvas` write/publish service executor before falling back to retained `Canvas`
execution on failure. PNG equirectangular export now writes a execution on failure. PNG equirectangular export now writes a
`pp_paint_renderer` equirectangular PNG from the same composited document `pp_paint_renderer` equirectangular PNG from the same composited document
frame before falling back to retained `Canvas` execution; JPEG/XMP, frame before falling back to retained `Canvas` execution; payload-complete
layer, animation-frame, depth, and video export remain on their prior layer and animation-frame PNG collections now write pure
retained writer paths. Actual broader writer replacement remains tracked `pp_paint_renderer` equirectangular PNG sequences through a tested app-core
under export debt. collection write/publish executor before retained fallback. JPEG/XMP,
Web prepared-file handoff, depth, video, and incomplete-readback collection
cases remain on their prior retained writer paths. Actual broader writer
replacement remains tracked under export debt.
- Snapshot creation now rejects invalid embedded RGBA8 face payloads before - Snapshot creation now rejects invalid embedded RGBA8 face payloads before
document export or history can persist malformed state. document export or history can persist malformed state.
- Package-smoke wrappers validate the Windows CMake app executable/runtime - Package-smoke wrappers validate the Windows CMake app executable/runtime

View File

@@ -41,6 +41,11 @@ struct DocumentCubeFaceExportPayload {
std::span<const std::byte> bytes; std::span<const std::byte> bytes;
}; };
struct DocumentExportCollectionPngPayload {
std::string path_suffix;
std::span<const std::byte> bytes;
};
struct DocumentExportSuggestedName { struct DocumentExportSuggestedName {
std::string name; std::string name;
}; };
@@ -190,6 +195,16 @@ public:
virtual void publish_exported_image(std::string_view path) = 0; virtual void publish_exported_image(std::string_view path) = 0;
}; };
class DocumentExportCollectionWriteServices {
public:
virtual ~DocumentExportCollectionWriteServices() = default;
virtual pp::foundation::Status write_binary_file(
std::string_view path,
std::span<const std::byte> bytes) = 0;
virtual void publish_exported_image(std::string_view path) = 0;
};
[[nodiscard]] constexpr DocumentExportStartDecision plan_document_export_start( [[nodiscard]] constexpr DocumentExportStartDecision plan_document_export_start(
bool requires_license, bool requires_license,
bool license_valid, bool license_valid,
@@ -608,6 +623,41 @@ document_cube_face_export_names() noexcept
return pp::foundation::Result<DocumentCubeFaceExportTarget>::success(std::move(target)); return pp::foundation::Result<DocumentCubeFaceExportTarget>::success(std::move(target));
} }
[[nodiscard]] inline std::string document_export_two_digit_index(std::size_t index)
{
auto value = std::to_string(index);
if (value.size() < 2U) {
value.insert(value.begin(), '0');
}
return value;
}
[[nodiscard]] inline std::string make_document_layer_export_path_suffix(
std::size_t layer_index,
std::string_view layer_name)
{
std::string suffix;
const auto index = document_export_two_digit_index(layer_index);
suffix.reserve(10U + index.size() + layer_name.size());
suffix += "-layer";
suffix += index;
suffix += "-";
suffix += layer_name;
suffix += ".png";
return suffix;
}
[[nodiscard]] inline std::string make_document_animation_frame_export_path_suffix(std::size_t frame_index)
{
std::string suffix;
const auto index = document_export_two_digit_index(frame_index);
suffix.reserve(5U + index.size());
suffix += "-";
suffix += index;
suffix += ".png";
return suffix;
}
[[nodiscard]] inline pp::foundation::Result<DocumentExportSuggestedName> make_document_export_suggested_name( [[nodiscard]] inline pp::foundation::Result<DocumentExportSuggestedName> make_document_export_suggested_name(
std::string_view document_name, std::string_view document_name,
std::string_view suffix) std::string_view suffix)
@@ -656,6 +706,41 @@ document_cube_face_export_names() noexcept
return pp::foundation::Status::success(); return pp::foundation::Status::success();
} }
[[nodiscard]] inline pp::foundation::Status execute_document_export_collection_write(
const DocumentExportCollectionTarget& target,
std::span<const DocumentExportCollectionPngPayload> payloads,
DocumentExportCollectionWriteServices& services)
{
if (target.stem_path.empty()) {
return pp::foundation::Status::invalid_argument("export collection target requires a stem path");
}
if (payloads.empty()) {
return pp::foundation::Status::invalid_argument("export collection payloads must not be empty");
}
for (const auto& payload : payloads) {
if (payload.path_suffix.empty()) {
return pp::foundation::Status::invalid_argument("export collection payload suffix must not be empty");
}
if (payload.bytes.empty()) {
return pp::foundation::Status::invalid_argument("export collection payload must not be empty");
}
std::string path;
path.reserve(target.stem_path.size() + payload.path_suffix.size());
path += target.stem_path;
path += payload.path_suffix;
const auto write_status = services.write_binary_file(path, payload.bytes);
if (!write_status.ok()) {
return write_status;
}
services.publish_exported_image(path);
}
return pp::foundation::Status::success();
}
[[nodiscard]] inline pp::foundation::Status execute_document_export_file( [[nodiscard]] inline pp::foundation::Status execute_document_export_file(
const DocumentExportFileTarget& target, const DocumentExportFileTarget& target,
DocumentExportServices& services) DocumentExportServices& services)

View File

@@ -65,9 +65,11 @@ pp::foundation::Status write_export_binary_file(std::string_view path, std::span
return pp::foundation::Status::success(); return pp::foundation::Status::success();
} }
class LegacyCubeFaceExportWriteServices final : public pp::app::DocumentCubeFaceExportWriteServices { class LegacyExportWriteServices final
: public pp::app::DocumentCubeFaceExportWriteServices
, public pp::app::DocumentExportCollectionWriteServices {
public: public:
explicit LegacyCubeFaceExportWriteServices(App& app) noexcept explicit LegacyExportWriteServices(App& app) noexcept
: app_(app) : app_(app)
{ {
} }
@@ -183,10 +185,90 @@ pp::foundation::Status export_cube_faces_from_document_snapshot(
payloads[face_index].bytes = std::span<const std::byte>(reports.face_pngs.face_pngs[face_index]); payloads[face_index].bytes = std::span<const std::byte>(reports.face_pngs.face_pngs[face_index]);
} }
LegacyCubeFaceExportWriteServices services(app); LegacyExportWriteServices services(app);
return pp::app::execute_document_cube_face_export_write(target.value(), payloads, services); return pp::app::execute_document_cube_face_export_write(target.value(), payloads, services);
} }
pp::foundation::Status export_layers_from_document_snapshot(
App& app,
const pp::app::DocumentExportCollectionTarget& target,
const LegacyDocumentExportSnapshotReports& reports)
{
const auto report = pp::app::make_document_canvas_save_snapshot_report(reports.snapshot);
if (!report.payload_complete) {
return pp::foundation::Status::invalid_argument(
"document snapshot layer export still requires renderer payload readback");
}
auto exported = pp::paint_renderer::export_document_layers_equirectangular_pngs(
pp::paint_renderer::DocumentLayerEquirectangularPngExportRequest {
.document = &reports.snapshot.document,
.frame_index = reports.snapshot.document.active_frame_index(),
.clear_color = {},
});
if (!exported) {
return exported.status();
}
auto exported_value = std::move(exported.value());
LOG(
"export-layers document export PNG writer: layers=%zu bytes=%llu activeFrame=%zu",
exported_value.layer_count,
static_cast<unsigned long long>(exported_value.encoded_bytes),
reports.snapshot.document.active_frame_index());
std::vector<pp::app::DocumentExportCollectionPngPayload> payloads;
payloads.reserve(exported_value.layers.size());
for (const auto& layer : exported_value.layers) {
payloads.push_back(pp::app::DocumentExportCollectionPngPayload {
.path_suffix = pp::app::make_document_layer_export_path_suffix(layer.layer_index, layer.layer_name),
.bytes = std::span<const std::byte>(layer.png.data(), layer.png.size()),
});
}
LegacyExportWriteServices services(app);
return pp::app::execute_document_export_collection_write(target, payloads, services);
}
pp::foundation::Status export_animation_frames_from_document_snapshot(
App& app,
const pp::app::DocumentExportCollectionTarget& target,
const LegacyDocumentExportSnapshotReports& reports)
{
const auto report = pp::app::make_document_canvas_save_snapshot_report(reports.snapshot);
if (!report.payload_complete) {
return pp::foundation::Status::invalid_argument(
"document snapshot animation-frame export still requires renderer payload readback");
}
auto exported = pp::paint_renderer::export_document_animation_frames_equirectangular_pngs(
pp::paint_renderer::DocumentAnimationFrameEquirectangularPngExportRequest {
.document = &reports.snapshot.document,
.clear_color = {},
});
if (!exported) {
return exported.status();
}
auto exported_value = std::move(exported.value());
LOG(
"export-animation-frames document export PNG writer: frames=%zu bytes=%llu",
exported_value.frame_count,
static_cast<unsigned long long>(exported_value.encoded_bytes));
std::vector<pp::app::DocumentExportCollectionPngPayload> payloads;
payloads.reserve(exported_value.frames.size());
for (const auto& frame : exported_value.frames) {
payloads.push_back(pp::app::DocumentExportCollectionPngPayload {
.path_suffix = pp::app::make_document_animation_frame_export_path_suffix(frame.frame_index),
.bytes = std::span<const std::byte>(frame.png.data(), frame.png.size()),
});
}
LegacyExportWriteServices services(app);
return pp::app::execute_document_export_collection_write(target, payloads, services);
}
pp::foundation::Status export_equirectangular_png_from_document_snapshot( pp::foundation::Status export_equirectangular_png_from_document_snapshot(
App& app, App& app,
const pp::app::DocumentExportFileTarget& target, const pp::app::DocumentExportFileTarget& target,
@@ -284,7 +366,32 @@ public:
void export_layers_to_stem(const pp::app::DocumentExportStemTarget& target) override void export_layers_to_stem(const pp::app::DocumentExportStemTarget& target) override
{ {
auto* app = &app_; auto* app = &app_;
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-layers");
if (prepared) {
const auto collection_target = pp::app::DocumentExportCollectionTarget {
.stem_path = target.stem_path,
};
const auto exported = export_layers_from_document_snapshot(app_, collection_target, prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::layers,
pp::app::DocumentExportSuccessDestination::path,
target.stem_path));
return;
}
LOG("export-layers document export writer retained legacy export after failure: %s", exported.message);
} else {
LOG(
"export-layers document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
}
#else
prepare_legacy_document_export_snapshot_or_continue(app_, "export-layers"); prepare_legacy_document_export_snapshot_or_continue(app_, "export-layers");
#endif
app_.canvas->m_canvas->export_layers(target.stem_path, [app, target] { app_.canvas->m_canvas->export_layers(target.stem_path, [app, target] {
show_export_success_dialog( show_export_success_dialog(
*app, *app,
@@ -298,7 +405,28 @@ public:
void export_layers_to_collection(const pp::app::DocumentExportCollectionTarget& target) override void export_layers_to_collection(const pp::app::DocumentExportCollectionTarget& target) override
{ {
auto* app = &app_; auto* app = &app_;
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-layers");
if (prepared) {
const auto exported = export_layers_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::layers,
pp::app::DocumentExportSuccessDestination::files_panopainter));
return;
}
LOG("export-layers document export writer retained legacy export after failure: %s", exported.message);
} else {
LOG(
"export-layers document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
}
#else
prepare_legacy_document_export_snapshot_or_continue(app_, "export-layers"); prepare_legacy_document_export_snapshot_or_continue(app_, "export-layers");
#endif
app_.canvas->m_canvas->export_layers(target.stem_path, [app] { app_.canvas->m_canvas->export_layers(target.stem_path, [app] {
show_export_success_dialog( show_export_success_dialog(
*app, *app,
@@ -311,7 +439,37 @@ public:
void export_animation_frames_to_stem(const pp::app::DocumentExportStemTarget& target) override void export_animation_frames_to_stem(const pp::app::DocumentExportStemTarget& target) override
{ {
auto* app = &app_; auto* app = &app_;
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-animation-frames");
if (prepared) {
const auto collection_target = pp::app::DocumentExportCollectionTarget {
.stem_path = target.stem_path,
};
const auto exported = export_animation_frames_from_document_snapshot(
app_,
collection_target,
prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::animation_frames,
pp::app::DocumentExportSuccessDestination::path,
target.stem_path));
return;
}
LOG(
"export-animation-frames document export writer retained legacy export after failure: %s",
exported.message);
} else {
LOG(
"export-animation-frames document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
}
#else
prepare_legacy_document_export_snapshot_or_continue(app_, "export-animation-frames"); prepare_legacy_document_export_snapshot_or_continue(app_, "export-animation-frames");
#endif
app_.canvas->m_canvas->export_anim_frames(target.stem_path, [app, target] { app_.canvas->m_canvas->export_anim_frames(target.stem_path, [app, target] {
show_export_success_dialog( show_export_success_dialog(
*app, *app,
@@ -325,7 +483,30 @@ public:
void export_animation_frames_to_collection(const pp::app::DocumentExportCollectionTarget& target) override void export_animation_frames_to_collection(const pp::app::DocumentExportCollectionTarget& target) override
{ {
auto* app = &app_; auto* app = &app_;
#if !__WEB__
const auto prepared = prepare_legacy_document_export_snapshot(app_, "export-animation-frames");
if (prepared) {
const auto exported = export_animation_frames_from_document_snapshot(app_, target, prepared.value());
if (exported.ok()) {
show_export_success_dialog(
app_,
pp::app::plan_document_export_success_dialog(
pp::app::DocumentExportSuccessKind::animation_frames,
pp::app::DocumentExportSuccessDestination::files_panopainter));
return;
}
LOG(
"export-animation-frames document export writer retained legacy export after failure: %s",
exported.message);
} else {
LOG(
"export-animation-frames document export snapshot bridge retained legacy export after failure: %s",
prepared.status().message);
}
#else
prepare_legacy_document_export_snapshot_or_continue(app_, "export-animation-frames"); prepare_legacy_document_export_snapshot_or_continue(app_, "export-animation-frames");
#endif
app_.canvas->m_canvas->export_anim_frames(target.stem_path, [app] { app_.canvas->m_canvas->export_anim_frames(target.stem_path, [app] {
show_export_success_dialog( show_export_success_dialog(
*app, *app,

View File

@@ -408,6 +408,77 @@ pp::foundation::Result<DocumentFrameCompositeResult> composite_document_frame(
return pp::foundation::Result<DocumentFrameCompositeResult>::success(std::move(result)); return pp::foundation::Result<DocumentFrameCompositeResult>::success(std::move(result));
} }
pp::foundation::Result<DocumentFrameCompositeResult> composite_document_layer_frame(
const pp::document::CanvasDocument& document,
std::size_t layer_index,
std::size_t frame_index,
pp::paint::Rgba clear_color)
{
if (layer_index >= document.layers().size()) {
return pp::foundation::Result<DocumentFrameCompositeResult>::failure(
pp::foundation::Status::out_of_range("document layer export index is outside the document"));
}
if (frame_index >= document.frames().size()) {
return pp::foundation::Result<DocumentFrameCompositeResult>::failure(
pp::foundation::Status::out_of_range("document layer export frame index is outside the document"));
}
const pp::renderer::Extent2D extent {
.width = document.width(),
.height = document.height(),
};
const auto pixel_count = expected_pixel_count(extent);
if (!pixel_count) {
return pp::foundation::Result<DocumentFrameCompositeResult>::failure(pixel_count.status());
}
const auto& source_layer = document.layers()[layer_index];
auto export_layer = source_layer;
export_layer.visible = true;
export_layer.opacity = 1.0F;
export_layer.blend_mode = pp::paint::BlendMode::normal;
DocumentFrameCompositeResult result;
result.extent = extent;
result.visited_layer_count = 1U;
for (std::uint32_t face_index = 0; face_index < pp::document::cube_face_count; ++face_index) {
DocumentFaceCompositeResult face;
face.extent = extent;
face.pixels.assign(pixel_count.value(), clear_color);
face.visited_layer_count = 1U;
if (frame_index < source_layer.frames.size()) {
bool composited_face = false;
const auto& frame = source_layer.frames[frame_index];
for (const auto& payload : frame.face_pixels) {
if (payload.face_index != face_index) {
continue;
}
const auto status = composite_face_payload(face.pixels, extent, payload, export_layer);
if (!status.ok()) {
return pp::foundation::Result<DocumentFrameCompositeResult>::failure(status);
}
composited_face = true;
++face.face_payload_count;
++result.face_payload_count;
}
if (composited_face) {
face.composited_layer_count = 1U;
++result.composited_layer_face_count;
}
}
result.faces[face_index] = std::move(face);
}
return pp::foundation::Result<DocumentFrameCompositeResult>::success(std::move(result));
}
pp::foundation::Result<DocumentFrameUploadResult> upload_document_frame_faces( pp::foundation::Result<DocumentFrameUploadResult> upload_document_frame_faces(
pp::renderer::IRenderDevice& device, pp::renderer::IRenderDevice& device,
DocumentFrameUploadRequest request) DocumentFrameUploadRequest request)
@@ -612,6 +683,91 @@ export_document_frame_equirectangular_png(DocumentFrameCompositeRequest request)
return export_document_frame_equirectangular_png(composite.value()); return export_document_frame_equirectangular_png(composite.value());
} }
pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>
export_document_layers_equirectangular_pngs(DocumentLayerEquirectangularPngExportRequest request)
{
if (request.document == nullptr) {
return pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>::failure(
pp::foundation::Status::invalid_argument("document layer export request requires a document"));
}
if (request.frame_index >= request.document->frames().size()) {
return pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>::failure(
pp::foundation::Status::out_of_range("document layer export frame index is outside the document"));
}
DocumentLayerEquirectangularPngExportResult result;
result.layers.reserve(request.document->layers().size());
for (std::size_t layer_index = 0; layer_index < request.document->layers().size(); ++layer_index) {
auto composite = composite_document_layer_frame(
*request.document,
layer_index,
request.frame_index,
request.clear_color);
if (!composite) {
return pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>::failure(composite.status());
}
auto exported = export_document_frame_equirectangular_png(composite.value());
if (!exported) {
return pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>::failure(exported.status());
}
DocumentLayerEquirectangularPng layer;
layer.layer_index = layer_index;
layer.layer_name = request.document->layers()[layer_index].name;
layer.face_extent = exported.value().face_extent;
layer.equirectangular_extent = exported.value().equirectangular_extent;
layer.encoded_bytes = exported.value().encoded_bytes;
layer.face_payload_count = exported.value().face_payload_count;
layer.composited_layer_face_count = exported.value().composited_layer_face_count;
layer.png = std::move(exported.value().png);
result.encoded_bytes += layer.encoded_bytes;
result.layers.push_back(std::move(layer));
}
result.layer_count = result.layers.size();
return pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>::success(std::move(result));
}
pp::foundation::Result<DocumentAnimationFrameEquirectangularPngExportResult>
export_document_animation_frames_equirectangular_pngs(
DocumentAnimationFrameEquirectangularPngExportRequest request)
{
if (request.document == nullptr) {
return pp::foundation::Result<DocumentAnimationFrameEquirectangularPngExportResult>::failure(
pp::foundation::Status::invalid_argument("document animation-frame export request requires a document"));
}
DocumentAnimationFrameEquirectangularPngExportResult result;
result.frames.reserve(request.document->frames().size());
for (std::size_t frame_index = 0; frame_index < request.document->frames().size(); ++frame_index) {
auto exported = export_document_frame_equirectangular_png(DocumentFrameCompositeRequest {
.document = request.document,
.frame_index = frame_index,
.clear_color = request.clear_color,
});
if (!exported) {
return pp::foundation::Result<DocumentAnimationFrameEquirectangularPngExportResult>::failure(
exported.status());
}
DocumentAnimationFrameEquirectangularPng frame;
frame.frame_index = frame_index;
frame.face_extent = exported.value().face_extent;
frame.equirectangular_extent = exported.value().equirectangular_extent;
frame.encoded_bytes = exported.value().encoded_bytes;
frame.face_payload_count = exported.value().face_payload_count;
frame.composited_layer_face_count = exported.value().composited_layer_face_count;
frame.png = std::move(exported.value().png);
result.encoded_bytes += frame.encoded_bytes;
result.frames.push_back(std::move(frame));
}
result.frame_count = result.frames.size();
return pp::foundation::Result<DocumentAnimationFrameEquirectangularPngExportResult>::success(std::move(result));
}
pp::foundation::Result<DocumentFrameExportReadinessResult> prepare_document_frame_export_readiness( pp::foundation::Result<DocumentFrameExportReadinessResult> prepare_document_frame_export_readiness(
DocumentFrameCompositeRequest request) DocumentFrameCompositeRequest request)
{ {

View File

@@ -10,6 +10,7 @@
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <span> #include <span>
#include <string>
#include <vector> #include <vector>
namespace pp::paint_renderer { namespace pp::paint_renderer {
@@ -155,6 +156,50 @@ struct DocumentFrameEquirectangularPngExportResult {
std::size_t composited_layer_face_count = 0; std::size_t composited_layer_face_count = 0;
}; };
struct DocumentLayerEquirectangularPngExportRequest {
const pp::document::CanvasDocument* document = nullptr;
std::size_t frame_index = 0;
pp::paint::Rgba clear_color {};
};
struct DocumentLayerEquirectangularPng {
std::size_t layer_index = 0;
std::string layer_name;
pp::renderer::Extent2D face_extent {};
pp::renderer::Extent2D equirectangular_extent {};
std::vector<std::byte> png;
std::uint64_t encoded_bytes = 0;
std::size_t face_payload_count = 0;
std::size_t composited_layer_face_count = 0;
};
struct DocumentLayerEquirectangularPngExportResult {
std::vector<DocumentLayerEquirectangularPng> layers;
std::uint64_t encoded_bytes = 0;
std::size_t layer_count = 0;
};
struct DocumentAnimationFrameEquirectangularPngExportRequest {
const pp::document::CanvasDocument* document = nullptr;
pp::paint::Rgba clear_color {};
};
struct DocumentAnimationFrameEquirectangularPng {
std::size_t frame_index = 0;
pp::renderer::Extent2D face_extent {};
pp::renderer::Extent2D equirectangular_extent {};
std::vector<std::byte> png;
std::uint64_t encoded_bytes = 0;
std::size_t face_payload_count = 0;
std::size_t composited_layer_face_count = 0;
};
struct DocumentAnimationFrameEquirectangularPngExportResult {
std::vector<DocumentAnimationFrameEquirectangularPng> frames;
std::uint64_t encoded_bytes = 0;
std::size_t frame_count = 0;
};
struct DocumentFrameExportReadinessResult { struct DocumentFrameExportReadinessResult {
RecordedDocumentFrameUploadResult recorded_upload {}; RecordedDocumentFrameUploadResult recorded_upload {};
DocumentFrameFacePngExportResult face_pngs {}; DocumentFrameFacePngExportResult face_pngs {};
@@ -187,6 +232,13 @@ export_document_frame_equirectangular_png(const DocumentFrameCompositeResult& co
[[nodiscard]] pp::foundation::Result<DocumentFrameEquirectangularPngExportResult> [[nodiscard]] pp::foundation::Result<DocumentFrameEquirectangularPngExportResult>
export_document_frame_equirectangular_png(DocumentFrameCompositeRequest request); export_document_frame_equirectangular_png(DocumentFrameCompositeRequest request);
[[nodiscard]] pp::foundation::Result<DocumentLayerEquirectangularPngExportResult>
export_document_layers_equirectangular_pngs(DocumentLayerEquirectangularPngExportRequest request);
[[nodiscard]] pp::foundation::Result<DocumentAnimationFrameEquirectangularPngExportResult>
export_document_animation_frames_equirectangular_pngs(
DocumentAnimationFrameEquirectangularPngExportRequest request);
[[nodiscard]] pp::foundation::Result<DocumentFrameExportReadinessResult> prepare_document_frame_export_readiness( [[nodiscard]] pp::foundation::Result<DocumentFrameExportReadinessResult> prepare_document_frame_export_readiness(
DocumentFrameCompositeRequest request); DocumentFrameCompositeRequest request);

View File

@@ -152,7 +152,9 @@ public:
std::string call_order; std::string call_order;
}; };
class FakeDocumentCubeFaceExportWriteServices final : public pp::app::DocumentCubeFaceExportWriteServices { class FakeDocumentCubeFaceExportWriteServices final
: public pp::app::DocumentCubeFaceExportWriteServices
, public pp::app::DocumentExportCollectionWriteServices {
public: public:
pp::foundation::Status write_binary_file( pp::foundation::Status write_binary_file(
std::string_view path, std::string_view path,
@@ -347,6 +349,96 @@ void cube_face_export_writer_rejects_malformed_inputs(pp::tests::Harness& harnes
PP_EXPECT(harness, services.publish_calls == 0); PP_EXPECT(harness, services.publish_calls == 0);
} }
void collection_export_builds_legacy_path_suffixes(pp::tests::Harness& harness)
{
PP_EXPECT(
harness,
pp::app::make_document_layer_export_path_suffix(0, "Base") == "-layer00-Base.png");
PP_EXPECT(
harness,
pp::app::make_document_layer_export_path_suffix(12, "Paint") == "-layer12-Paint.png");
PP_EXPECT(
harness,
pp::app::make_document_animation_frame_export_path_suffix(0) == "-00.png");
PP_EXPECT(
harness,
pp::app::make_document_animation_frame_export_path_suffix(125) == "-125.png");
}
void collection_export_writer_writes_and_publishes_payloads_in_order(pp::tests::Harness& harness)
{
const auto target = pp::app::make_document_export_collection_target("D:/Paint", "demo", "_layers");
const std::array<std::byte, 2> base_bytes {};
const std::array<std::byte, 3> paint_bytes {};
const pp::app::DocumentExportCollectionPngPayload payloads[] {
{
.path_suffix = pp::app::make_document_layer_export_path_suffix(0, "Base"),
.bytes = std::span<const std::byte>(base_bytes),
},
{
.path_suffix = pp::app::make_document_layer_export_path_suffix(1, "Paint"),
.bytes = std::span<const std::byte>(paint_bytes),
},
};
FakeDocumentCubeFaceExportWriteServices services;
PP_EXPECT(harness, target);
PP_EXPECT(
harness,
pp::app::execute_document_export_collection_write(target.value(), payloads, services).ok());
PP_EXPECT(harness, services.write_calls == 2);
PP_EXPECT(harness, services.publish_calls == 2);
PP_EXPECT(harness, services.total_bytes == 5U);
PP_EXPECT(harness, services.last_path == "D:/Paint/demo_layers/demo-layer01-Paint.png");
PP_EXPECT(
harness,
services.call_order
== "write:D:/Paint/demo_layers/demo-layer00-Base.png;"
"publish:D:/Paint/demo_layers/demo-layer00-Base.png;"
"write:D:/Paint/demo_layers/demo-layer01-Paint.png;"
"publish:D:/Paint/demo_layers/demo-layer01-Paint.png;");
}
void collection_export_writer_rejects_malformed_inputs(pp::tests::Harness& harness)
{
const auto target = pp::app::make_document_export_collection_target("D:/Paint", "demo", "_frames");
const std::array<std::byte, 2> bytes {};
pp::app::DocumentExportCollectionPngPayload payloads[] {
{
.path_suffix = pp::app::make_document_animation_frame_export_path_suffix(0),
.bytes = std::span<const std::byte>(bytes),
},
};
FakeDocumentCubeFaceExportWriteServices services;
PP_EXPECT(harness, target);
auto empty_stem = target.value();
empty_stem.stem_path.clear();
PP_EXPECT(
harness,
!pp::app::execute_document_export_collection_write(empty_stem, payloads, services).ok());
PP_EXPECT(
harness,
!pp::app::execute_document_export_collection_write(
target.value(),
std::span<const pp::app::DocumentExportCollectionPngPayload>(),
services)
.ok());
payloads[0].path_suffix.clear();
PP_EXPECT(
harness,
!pp::app::execute_document_export_collection_write(target.value(), payloads, services).ok());
payloads[0].path_suffix = pp::app::make_document_animation_frame_export_path_suffix(0);
payloads[0].bytes = {};
PP_EXPECT(
harness,
!pp::app::execute_document_export_collection_write(target.value(), payloads, services).ok());
PP_EXPECT(harness, services.write_calls == 0);
PP_EXPECT(harness, services.publish_calls == 0);
}
void video_export_builds_suggested_name(pp::tests::Harness& harness) void video_export_builds_suggested_name(pp::tests::Harness& harness)
{ {
const auto timelapse = pp::app::make_document_export_suggested_name("demo", "-timelapse"); const auto timelapse = pp::app::make_document_export_suggested_name("demo", "-timelapse");
@@ -891,6 +983,11 @@ int main()
"cube face export writer stops before publish on write failure", "cube face export writer stops before publish on write failure",
cube_face_export_writer_stops_before_publish_on_write_failure); cube_face_export_writer_stops_before_publish_on_write_failure);
harness.run("cube face export writer rejects malformed inputs", cube_face_export_writer_rejects_malformed_inputs); harness.run("cube face export writer rejects malformed inputs", cube_face_export_writer_rejects_malformed_inputs);
harness.run("collection export builds legacy path suffixes", collection_export_builds_legacy_path_suffixes);
harness.run(
"collection export writer writes and publishes payloads in order",
collection_export_writer_writes_and_publishes_payloads_in_order);
harness.run("collection export writer rejects malformed inputs", collection_export_writer_rejects_malformed_inputs);
harness.run("video export builds suggested name", video_export_builds_suggested_name); harness.run("video export builds suggested name", video_export_builds_suggested_name);
harness.run("collection export target plan selects platform destination", collection_export_target_plan_selects_platform_destination); harness.run("collection export target plan selects platform destination", collection_export_target_plan_selects_platform_destination);
harness.run("export success dialog plans image destinations", export_success_dialog_plans_image_destinations); harness.run("export success dialog plans image destinations", export_success_dialog_plans_image_destinations);

View File

@@ -46,6 +46,60 @@ bool near(float a, float b)
return std::fabs(a - b) < 0.0001F; return std::fabs(a - b) < 0.0001F;
} }
std::vector<std::uint8_t> solid_rgba8(
std::uint32_t width,
std::uint32_t height,
std::uint8_t r,
std::uint8_t g,
std::uint8_t b,
std::uint8_t a)
{
std::vector<std::uint8_t> pixels(
static_cast<std::size_t>(width) * height * pp::document::rgba8_components);
for (std::size_t i = 0; i < pixels.size(); i += pp::document::rgba8_components) {
pixels[i] = r;
pixels[i + 1U] = g;
pixels[i + 2U] = b;
pixels[i + 3U] = a;
}
return pixels;
}
LayerFacePixels solid_face_payload(
std::uint32_t face_index,
std::uint32_t width,
std::uint32_t height,
std::uint8_t r,
std::uint8_t g,
std::uint8_t b,
std::uint8_t a)
{
return LayerFacePixels {
.face_index = face_index,
.x = 0,
.y = 0,
.width = width,
.height = height,
.rgba8 = solid_rgba8(width, height, r, g, b, a),
};
}
std::vector<LayerFacePixels> solid_cube_faces(
std::uint32_t width,
std::uint32_t height,
std::uint8_t r,
std::uint8_t g,
std::uint8_t b,
std::uint8_t a)
{
std::vector<LayerFacePixels> faces;
faces.reserve(pp::document::cube_face_count);
for (std::uint32_t face_index = 0; face_index < pp::document::cube_face_count; ++face_index) {
faces.push_back(solid_face_payload(face_index, width, height, r, g, b, a));
}
return faces;
}
void composites_visible_layer_with_opacity(pp::tests::Harness& h) void composites_visible_layer_with_opacity(pp::tests::Harness& h)
{ {
std::vector<Rgba> destination { std::vector<Rgba> destination {
@@ -848,6 +902,140 @@ void exports_document_frame_as_equirectangular_png(pp::tests::Harness& h)
PP_EXPECT(h, decoded.value().pixels[bottom + 2U] == 255U); PP_EXPECT(h, decoded.value().pixels[bottom + 2U] == 255U);
} }
void exports_document_layers_as_equirectangular_pngs(pp::tests::Harness& h)
{
const AnimationFrame root_frames[] {
{ .duration_ms = 100, .face_pixels = {} },
};
const AnimationFrame base_frames[] {
{
.duration_ms = 100,
.face_pixels = solid_cube_faces(1, 4, 255, 0, 0, 255),
},
};
const AnimationFrame hidden_frames[] {
{
.duration_ms = 100,
.face_pixels = solid_cube_faces(1, 4, 0, 0, 255, 255),
},
};
const DocumentLayerConfig layers[] {
{
.name = "Base",
.frames = std::span<const AnimationFrame>(base_frames, 1),
},
{
.name = "HiddenPaint",
.visible = false,
.opacity = 0.0F,
.frames = std::span<const AnimationFrame>(hidden_frames, 1),
},
};
const auto document = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 1,
.height = 4,
.layers = std::span<const DocumentLayerConfig>(layers, 2),
.frames = std::span<const AnimationFrame>(root_frames, 1),
.selection_masks = {},
});
PP_EXPECT(h, document);
if (!document) {
return;
}
const auto exported = pp::paint_renderer::export_document_layers_equirectangular_pngs(
pp::paint_renderer::DocumentLayerEquirectangularPngExportRequest {
.document = &document.value(),
.frame_index = 0,
});
PP_EXPECT(h, exported);
if (!exported) {
return;
}
PP_EXPECT(h, exported.value().layer_count == 2U);
PP_EXPECT(h, exported.value().layers.size() == 2U);
PP_EXPECT(h, exported.value().layers[0].layer_name == "Base");
PP_EXPECT(h, exported.value().layers[1].layer_name == "HiddenPaint");
PP_EXPECT(h, exported.value().layers[0].face_payload_count == pp::document::cube_face_count);
PP_EXPECT(h, exported.value().layers[1].face_payload_count == pp::document::cube_face_count);
PP_EXPECT(h, exported.value().encoded_bytes > 0U);
const auto decoded = pp::assets::decode_png_rgba8(exported.value().layers[1].png);
PP_EXPECT(h, decoded);
if (!decoded) {
return;
}
PP_EXPECT(h, decoded.value().width == 4U);
PP_EXPECT(h, decoded.value().height == 8U);
PP_EXPECT(h, decoded.value().pixels[0] == 0U);
PP_EXPECT(h, decoded.value().pixels[1] == 0U);
PP_EXPECT(h, decoded.value().pixels[2] == 255U);
}
void exports_document_animation_frames_as_equirectangular_pngs(pp::tests::Harness& h)
{
const AnimationFrame root_frames[] {
{ .duration_ms = 100, .face_pixels = {} },
{ .duration_ms = 100, .face_pixels = {} },
};
const AnimationFrame layer_frames[] {
{
.duration_ms = 100,
.face_pixels = solid_cube_faces(1, 4, 255, 0, 0, 255),
},
{
.duration_ms = 100,
.face_pixels = solid_cube_faces(1, 4, 0, 255, 0, 255),
},
};
const DocumentLayerConfig layers[] {
{
.name = "Paint",
.frames = std::span<const AnimationFrame>(layer_frames, 2),
},
};
const auto document = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 1,
.height = 4,
.layers = std::span<const DocumentLayerConfig>(layers, 1),
.frames = std::span<const AnimationFrame>(root_frames, 2),
.selection_masks = {},
});
PP_EXPECT(h, document);
if (!document) {
return;
}
const auto exported = pp::paint_renderer::export_document_animation_frames_equirectangular_pngs(
pp::paint_renderer::DocumentAnimationFrameEquirectangularPngExportRequest {
.document = &document.value(),
});
PP_EXPECT(h, exported);
if (!exported) {
return;
}
PP_EXPECT(h, exported.value().frame_count == 2U);
PP_EXPECT(h, exported.value().frames.size() == 2U);
PP_EXPECT(h, exported.value().frames[0].frame_index == 0U);
PP_EXPECT(h, exported.value().frames[1].frame_index == 1U);
PP_EXPECT(h, exported.value().encoded_bytes > 0U);
const auto decoded = pp::assets::decode_png_rgba8(exported.value().frames[1].png);
PP_EXPECT(h, decoded);
if (!decoded) {
return;
}
PP_EXPECT(h, decoded.value().width == 4U);
PP_EXPECT(h, decoded.value().height == 8U);
PP_EXPECT(h, decoded.value().pixels[0] == 0U);
PP_EXPECT(h, decoded.value().pixels[1] == 255U);
PP_EXPECT(h, decoded.value().pixels[2] == 0U);
}
void document_frame_upload_rejects_invalid_requests(pp::tests::Harness& h) void document_frame_upload_rejects_invalid_requests(pp::tests::Harness& h)
{ {
RecordingRenderDevice device; RecordingRenderDevice device;
@@ -858,6 +1046,10 @@ void document_frame_upload_rejects_invalid_requests(pp::tests::Harness& h)
DocumentFrameCompositeRequest {}); DocumentFrameCompositeRequest {});
const auto no_document_equirect = pp::paint_renderer::export_document_frame_equirectangular_png( const auto no_document_equirect = pp::paint_renderer::export_document_frame_equirectangular_png(
DocumentFrameCompositeRequest {}); DocumentFrameCompositeRequest {});
const auto no_document_layers = pp::paint_renderer::export_document_layers_equirectangular_pngs(
pp::paint_renderer::DocumentLayerEquirectangularPngExportRequest {});
const auto no_document_frames = pp::paint_renderer::export_document_animation_frames_equirectangular_pngs(
pp::paint_renderer::DocumentAnimationFrameEquirectangularPngExportRequest {});
const AnimationFrame root_frames[] { const AnimationFrame root_frames[] {
{ .duration_ms = 100, .face_pixels = {} }, { .duration_ms = 100, .face_pixels = {} },
@@ -892,6 +1084,10 @@ void document_frame_upload_rejects_invalid_requests(pp::tests::Harness& h)
PP_EXPECT(h, no_document_readiness.status().code == StatusCode::invalid_argument); PP_EXPECT(h, no_document_readiness.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !no_document_equirect.ok()); PP_EXPECT(h, !no_document_equirect.ok());
PP_EXPECT(h, no_document_equirect.status().code == StatusCode::invalid_argument); PP_EXPECT(h, no_document_equirect.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !no_document_layers.ok());
PP_EXPECT(h, no_document_layers.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !no_document_frames.ok());
PP_EXPECT(h, no_document_frames.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_frame.ok()); PP_EXPECT(h, !bad_frame.ok());
PP_EXPECT(h, bad_frame.status().code == StatusCode::out_of_range); PP_EXPECT(h, bad_frame.status().code == StatusCode::out_of_range);
PP_EXPECT(h, !bad_frame_readiness.ok()); PP_EXPECT(h, !bad_frame_readiness.ok());
@@ -1248,6 +1444,10 @@ int main()
harness.run("exports_document_frame_faces_as_pngs", exports_document_frame_faces_as_pngs); harness.run("exports_document_frame_faces_as_pngs", exports_document_frame_faces_as_pngs);
harness.run("prepares_document_frame_export_readiness_report", prepares_document_frame_export_readiness_report); harness.run("prepares_document_frame_export_readiness_report", prepares_document_frame_export_readiness_report);
harness.run("exports_document_frame_as_equirectangular_png", exports_document_frame_as_equirectangular_png); harness.run("exports_document_frame_as_equirectangular_png", exports_document_frame_as_equirectangular_png);
harness.run("exports_document_layers_as_equirectangular_pngs", exports_document_layers_as_equirectangular_pngs);
harness.run(
"exports_document_animation_frames_as_equirectangular_pngs",
exports_document_animation_frames_as_equirectangular_pngs);
harness.run("document_frame_upload_rejects_invalid_requests", document_frame_upload_rejects_invalid_requests); harness.run("document_frame_upload_rejects_invalid_requests", document_frame_upload_rejects_invalid_requests);
harness.run("detects_feedback_requirements", detects_feedback_requirements); harness.run("detects_feedback_requirements", detects_feedback_requirements);
harness.run("plans_stroke_composite_paths", plans_stroke_composite_paths); harness.run("plans_stroke_composite_paths", plans_stroke_composite_paths);