Color antialiased half-edges by ray outcome with configurable Catppuccin colors and default opacity 0.5. Rasterize premultiplied RGBA8 overlays on the producer and composite in place after writing the clean image. Keep single-frame, movie, and replay output consistent. Add overlay, CLI, and queue ownership regressions and document the final output architecture.
495 lines
17 KiB
C
495 lines
17 KiB
C
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "movie_output.h"
|
|
|
|
#include <errno.h>
|
|
#include <math.h>
|
|
#include <omp.h>
|
|
#include <setjmp.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#ifdef ENABLE_PNG
|
|
#include <png.h>
|
|
#endif
|
|
|
|
static int failures = 0;
|
|
|
|
static void check(int condition, const char *message) {
|
|
if (!condition) {
|
|
fprintf(stderr, "FAIL: %s\n", message);
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
/* Scratch data lives in the pre-approved OpenCode scratch directory rather
|
|
* than directly under /tmp. */
|
|
#define TEST_TMP_ROOT "/tmp/opencode"
|
|
#ifdef ENABLE_PNG
|
|
#define TEST_IMAGE_EXT "png"
|
|
#else
|
|
#define TEST_IMAGE_EXT "ppm"
|
|
#endif
|
|
|
|
#define MAX_JOBS 64
|
|
#define JOB_WIDTH 16
|
|
#define JOB_HEIGHT 16
|
|
#define JOB_BYTES ((size_t)JOB_WIDTH * JOB_HEIGHT * 3)
|
|
|
|
typedef struct {
|
|
int fail_at;
|
|
int delay_ms;
|
|
size_t count;
|
|
size_t order[MAX_JOBS];
|
|
unsigned char pixels[MAX_JOBS][JOB_BYTES];
|
|
int mesh_draw_flags[MAX_JOBS];
|
|
size_t mesh_pixel_counts[MAX_JOBS];
|
|
unsigned char layers[MAX_JOBS][JOB_WIDTH * JOB_HEIGHT * 4];
|
|
} MockWriter;
|
|
|
|
static int mock_write(void *context, const MovieOutputJob *job,
|
|
const PngWriteSettings *settings) {
|
|
(void)settings;
|
|
MockWriter *mock = context;
|
|
if (mock->count < MAX_JOBS) {
|
|
mock->order[mock->count] = job->frame_id;
|
|
memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES);
|
|
mock->mesh_draw_flags[mock->count] = job->draw_mesh;
|
|
mock->mesh_pixel_counts[mock->count] = job->mesh_layer.rgba == NULL ? 0 :
|
|
(size_t)job->mesh_layer.width * job->mesh_layer.height;
|
|
if (job->mesh_layer.rgba != NULL)
|
|
memcpy(mock->layers[mock->count], job->mesh_layer.rgba,
|
|
sizeof mock->layers[mock->count]);
|
|
}
|
|
++mock->count;
|
|
if (mock->delay_ms > 0) {
|
|
const struct timespec delay = {.tv_sec = mock->delay_ms / 1000,
|
|
.tv_nsec =
|
|
(long)(mock->delay_ms % 1000) * 1000000L};
|
|
nanosleep(&delay, NULL);
|
|
}
|
|
return mock->fail_at >= 0 && (int)job->frame_id == mock->fail_at ? -1 : 0;
|
|
}
|
|
|
|
static unsigned char *make_rgb8(size_t frame_id) {
|
|
unsigned char *rgb8 = malloc(JOB_BYTES);
|
|
if (rgb8 == NULL)
|
|
return NULL;
|
|
for (size_t i = 0; i < JOB_BYTES; ++i)
|
|
rgb8[i] = (unsigned char)((frame_id * 31 + i) & 0xff);
|
|
return rgb8;
|
|
}
|
|
|
|
static MovieOutputJob make_job(size_t frame_id) {
|
|
MovieOutputJob job;
|
|
memset(&job, 0, sizeof job);
|
|
job.frame_id = frame_id;
|
|
job.width = JOB_WIDTH;
|
|
job.height = JOB_HEIGHT;
|
|
job.images = frame_id + 1;
|
|
job.catalog_stars = 7;
|
|
job.clean_rgb8 = make_rgb8(frame_id);
|
|
return job;
|
|
}
|
|
|
|
/* An independent premultiplied RGBA8 fixture with sparse nonzero pixels. */
|
|
static int make_layer(MeshOverlayLayer *layer, size_t count) {
|
|
*layer = (MeshOverlayLayer){0};
|
|
layer->rgba = calloc(JOB_WIDTH * JOB_HEIGHT, 4);
|
|
if (layer->rgba == NULL)
|
|
return -1;
|
|
layer->width = JOB_WIDTH;
|
|
layer->height = JOB_HEIGHT;
|
|
for (size_t i = 0; i < count; ++i) {
|
|
for (int channel = 0; channel < 3; ++channel)
|
|
layer->rgba[4 * i + channel] = (unsigned char)((i * 19 + channel * 3) % 129);
|
|
layer->rgba[4 * i + 3] = 128;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Order and pixel fidelity for capacity 1 and 2. */
|
|
static void test_order_and_pixels(size_t capacity) {
|
|
MovieOutputQueue queue;
|
|
MockWriter mock;
|
|
memset(&mock, 0, sizeof mock);
|
|
mock.fail_at = -1;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, capacity, &settings)) {
|
|
check(0, "order: queue init");
|
|
return;
|
|
}
|
|
movie_output_queue_set_writer(&queue, mock_write, &mock);
|
|
const size_t frames = 5;
|
|
int all_ok = 1;
|
|
for (size_t f = 0; f < frames; ++f) {
|
|
MovieOutputJob job = make_job(f);
|
|
if (job.clean_rgb8 == NULL ||
|
|
movie_output_queue_submit(&queue, &job, NULL)) {
|
|
all_ok = 0;
|
|
free(job.clean_rgb8);
|
|
break;
|
|
}
|
|
}
|
|
const int finish = movie_output_queue_finish(&queue);
|
|
check(all_ok && finish == 0, "order: submit/finish");
|
|
check(mock.count == frames, "order: writer saw every job");
|
|
for (size_t f = 0; f < frames && f < MAX_JOBS; ++f) {
|
|
check(mock.order[f] == f, "order: frames written in submit order");
|
|
unsigned char *expected = make_rgb8(f);
|
|
if (expected != NULL) {
|
|
check(memcmp(mock.pixels[f], expected, JOB_BYTES) == 0,
|
|
"order: pixel payload preserved");
|
|
free(expected);
|
|
}
|
|
}
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
/* A slow writer must push back on the producer at capacity 1. */
|
|
static void test_backpressure(void) {
|
|
MovieOutputQueue queue;
|
|
MockWriter mock;
|
|
memset(&mock, 0, sizeof mock);
|
|
mock.fail_at = -1;
|
|
mock.delay_ms = 40;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "backpressure: queue init");
|
|
return;
|
|
}
|
|
movie_output_queue_set_writer(&queue, mock_write, &mock);
|
|
double total_wait = 0.0;
|
|
int all_ok = 1;
|
|
for (size_t f = 0; f < 5; ++f) {
|
|
MovieOutputJob job = make_job(f);
|
|
double wait = 0.0;
|
|
if (movie_output_queue_submit(&queue, &job, &wait)) {
|
|
all_ok = 0;
|
|
free(job.clean_rgb8);
|
|
break;
|
|
}
|
|
total_wait += wait;
|
|
}
|
|
check(all_ok && movie_output_queue_finish(&queue) == 0,
|
|
"backpressure: submit/finish");
|
|
check(total_wait > 0.05, "backpressure: producer waited on a full queue");
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
/* Writer failure at frame N must propagate, unblock the producer, and join
|
|
* cleanly. RGBA layers are attached to every job so both ownership paths
|
|
* are exercised: the queue releases accepted jobs, while the caller releases
|
|
* the job rejected after the failure is recorded. */
|
|
static void test_writer_failure(void) {
|
|
MovieOutputQueue queue;
|
|
MockWriter mock;
|
|
memset(&mock, 0, sizeof mock);
|
|
mock.fail_at = 3;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, 2, &settings)) {
|
|
check(0, "failure: queue init");
|
|
return;
|
|
}
|
|
movie_output_queue_set_writer(&queue, mock_write, &mock);
|
|
int saw_failure = 0;
|
|
for (size_t f = 0; f < 8; ++f) {
|
|
MovieOutputJob job = make_job(f);
|
|
job.draw_mesh = 1;
|
|
if (make_layer(&job.mesh_layer, 3)) {
|
|
free(job.clean_rgb8);
|
|
check(0, "failure: layer allocation");
|
|
break;
|
|
}
|
|
if (movie_output_queue_submit(&queue, &job, NULL)) {
|
|
/* The queue no longer owns these resources. */
|
|
free(job.clean_rgb8);
|
|
mesh_overlay_layer_destroy(&job.mesh_layer);
|
|
saw_failure = 1;
|
|
break;
|
|
}
|
|
}
|
|
const int finish = movie_output_queue_finish(&queue);
|
|
check(saw_failure, "failure: a later submit reported the writer error");
|
|
check(finish != 0, "failure: finish reports the recorded error");
|
|
check(movie_output_queue_failed(&queue), "failure: failed flag persists");
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
/* Empty finish and never-submitted init must not hang or leak. */
|
|
static void test_empty_paths(void) {
|
|
const PngWriteSettings settings = {-1};
|
|
MovieOutputQueue queue;
|
|
if (movie_output_queue_init(&queue, 2, &settings)) {
|
|
check(0, "empty: queue init");
|
|
return;
|
|
}
|
|
check(movie_output_queue_finish(&queue) == 0, "empty: finish with no jobs");
|
|
movie_output_queue_destroy(&queue);
|
|
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "empty: second init");
|
|
return;
|
|
}
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
/* The queued job owns an independent RGBA layer: rasterizing a heap mesh, then
|
|
* releasing that source mesh before the writer runs, must not disturb the
|
|
* pixels the writer observes. */
|
|
static void test_lines_survive_source_release(void) {
|
|
LensVertex *vertices = calloc(3, sizeof *vertices);
|
|
LensTriangle *triangles = calloc(1, sizeof *triangles);
|
|
if (vertices == NULL || triangles == NULL) {
|
|
free(vertices);
|
|
free(triangles);
|
|
check(0, "source release: allocation");
|
|
return;
|
|
}
|
|
vertices[0] = (LensVertex){.image_x = 1.5,
|
|
.image_y = 1.5,
|
|
.outcome = RAY_OUTCOME_ESCAPED,
|
|
.traced = 1};
|
|
vertices[1] = (LensVertex){.image_x = JOB_WIDTH - 2.5,
|
|
.image_y = 2.5,
|
|
.outcome = RAY_OUTCOME_DARK,
|
|
.traced = 1};
|
|
vertices[2] = (LensVertex){.image_x = 5.5,
|
|
.image_y = JOB_HEIGHT - 2.5,
|
|
.outcome = RAY_OUTCOME_INCOMPLETE,
|
|
.traced = 0};
|
|
triangles[0].vertex[0] = 0;
|
|
triangles[0].vertex[1] = 1;
|
|
triangles[0].vertex[2] = 2;
|
|
FrameLensMesh mesh = {.vertices = vertices,
|
|
.vertex_count = 3,
|
|
.triangles = triangles,
|
|
.triangle_count = 1};
|
|
MeshOverlayLayer layer = {0};
|
|
const MeshOverlaySettings overlay = mesh_overlay_default_settings();
|
|
const int prepared = mesh_overlay_build_layer(&mesh, JOB_WIDTH, JOB_HEIGHT,
|
|
&overlay, &layer) == 0;
|
|
/* Release the source mesh (and its vertex/triangle arrays) before submit. */
|
|
free(vertices);
|
|
free(triangles);
|
|
check(prepared, "source release: overlay extraction");
|
|
|
|
MovieOutputQueue queue;
|
|
MockWriter mock;
|
|
memset(&mock, 0, sizeof mock);
|
|
mock.fail_at = -1;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "source release: queue init");
|
|
mesh_overlay_layer_destroy(&layer);
|
|
return;
|
|
}
|
|
movie_output_queue_set_writer(&queue, mock_write, &mock);
|
|
MovieOutputJob job = make_job(0);
|
|
job.draw_mesh = 1;
|
|
job.mesh_layer = layer; /* ownership transferred to the job/queue */
|
|
int submitted = job.clean_rgb8 != NULL &&
|
|
movie_output_queue_submit(&queue, &job, NULL) == 0;
|
|
if (!submitted) {
|
|
free(job.clean_rgb8);
|
|
mesh_overlay_layer_destroy(&job.mesh_layer);
|
|
}
|
|
check(submitted && movie_output_queue_finish(&queue) == 0,
|
|
"source release: submit/finish");
|
|
check(mock.count == 1 && mock.mesh_draw_flags[0] == 1 &&
|
|
mock.mesh_pixel_counts[0] == JOB_WIDTH * JOB_HEIGHT &&
|
|
memcmp(mock.layers[0], (unsigned char[JOB_WIDTH * JOB_HEIGHT * 4]){0},
|
|
sizeof mock.layers[0]) != 0,
|
|
"source release: writer saw the intact RGBA overlay");
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
#ifdef ENABLE_PNG
|
|
static int decode_png_rgb8(const char *path, unsigned char *out, int width,
|
|
int height) {
|
|
FILE *file = fopen(path, "rb");
|
|
if (file == NULL)
|
|
return -1;
|
|
png_structp png =
|
|
png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
|
|
png_infop info = png != NULL ? png_create_info_struct(png) : NULL;
|
|
int ok = 0;
|
|
if (png != NULL && info != NULL && !setjmp(png_jmpbuf(png))) {
|
|
png_init_io(png, file);
|
|
png_read_info(png, info);
|
|
for (int row = 0; row < height; ++row)
|
|
png_read_row(png, &out[(size_t)row * width * 3], NULL);
|
|
png_read_end(png, info);
|
|
ok = 1;
|
|
}
|
|
fclose(file);
|
|
png_destroy_read_struct(&png, &info, NULL);
|
|
return ok ? 0 : -1;
|
|
}
|
|
|
|
/* The default writer's clean file must decode unchanged and its mesh sibling
|
|
* must equal the core overlay drawn in place on that same clean payload. */
|
|
static void test_default_writer_mesh(void) {
|
|
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
|
|
if (mkdtemp(directory) == NULL) {
|
|
check(0, "default mesh: mkdtemp");
|
|
return;
|
|
}
|
|
char clean_path[PATH_MAX];
|
|
char mesh_path[PATH_MAX];
|
|
snprintf(clean_path, sizeof clean_path, "%s/frame_000000.png", directory);
|
|
snprintf(mesh_path, sizeof mesh_path, "%s/frame_000000_mesh.png", directory);
|
|
const PngWriteSettings settings = {-1};
|
|
MovieOutputQueue queue;
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "default mesh: queue init");
|
|
rmdir(directory);
|
|
return;
|
|
}
|
|
MovieOutputJob job = make_job(0);
|
|
job.draw_mesh = 1;
|
|
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
|
|
snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path);
|
|
int layer_ok = make_layer(&job.mesh_layer, 5) == 0;
|
|
unsigned char *clean_expected = make_rgb8(0);
|
|
unsigned char mesh_expected[JOB_BYTES];
|
|
int expected_ok = layer_ok && clean_expected != NULL;
|
|
if (expected_ok) {
|
|
memcpy(mesh_expected, clean_expected, JOB_BYTES);
|
|
expected_ok = mesh_overlay_composite_rgb8(&job.mesh_layer, mesh_expected,
|
|
JOB_WIDTH, JOB_HEIGHT) == 0;
|
|
}
|
|
int submitted = expected_ok && job.clean_rgb8 != NULL &&
|
|
movie_output_queue_submit(&queue, &job, NULL) == 0;
|
|
if (!submitted) {
|
|
free(job.clean_rgb8);
|
|
mesh_overlay_layer_destroy(&job.mesh_layer);
|
|
}
|
|
check(submitted && movie_output_queue_finish(&queue) == 0,
|
|
"default mesh: submit/finish");
|
|
unsigned char clean_decoded[JOB_BYTES];
|
|
unsigned char mesh_decoded[JOB_BYTES];
|
|
const int decoded_ok =
|
|
decode_png_rgb8(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 &&
|
|
decode_png_rgb8(mesh_path, mesh_decoded, JOB_WIDTH, JOB_HEIGHT) == 0;
|
|
check(decoded_ok && clean_expected != NULL &&
|
|
memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
|
|
"default mesh: clean image unchanged");
|
|
check(decoded_ok && expected_ok &&
|
|
memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
|
|
"default mesh: mesh matches core overlay result");
|
|
free(clean_expected);
|
|
unlink(clean_path);
|
|
unlink(mesh_path);
|
|
rmdir(directory);
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
#endif
|
|
|
|
/* Failure while writing the clean image happens before any overlay work; the
|
|
* queue must still release the job's resources. */
|
|
static void test_default_writer_failure_before_overlay(void) {
|
|
MovieOutputQueue queue;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "failure before overlay: queue init");
|
|
return;
|
|
}
|
|
MovieOutputJob job = make_job(0);
|
|
job.draw_mesh = 1;
|
|
if (make_layer(&job.mesh_layer, 4)) {
|
|
free(job.clean_rgb8);
|
|
check(0, "failure before overlay: layer allocation");
|
|
movie_output_queue_destroy(&queue);
|
|
return;
|
|
}
|
|
snprintf(job.output_path, sizeof job.output_path,
|
|
"/nonexistent-directory-xyz/frame." TEST_IMAGE_EXT);
|
|
snprintf(job.mesh_path, sizeof job.mesh_path,
|
|
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
|
|
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
|
|
free(job.clean_rgb8);
|
|
mesh_overlay_layer_destroy(&job.mesh_layer);
|
|
check(0, "failure before overlay: submit");
|
|
movie_output_queue_destroy(&queue);
|
|
return;
|
|
}
|
|
check(movie_output_queue_finish(&queue) != 0,
|
|
"failure before overlay: clean write error propagates");
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
/* Failure while writing the mesh image happens after the clean file is written
|
|
* and the overlay is drawn; that later error must also propagate. */
|
|
static void test_default_writer_failure_after_overlay(void) {
|
|
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
|
|
if (mkdtemp(directory) == NULL) {
|
|
check(0, "failure after overlay: mkdtemp");
|
|
return;
|
|
}
|
|
char clean_path[PATH_MAX];
|
|
snprintf(clean_path, sizeof clean_path, "%s/frame_000000." TEST_IMAGE_EXT,
|
|
directory);
|
|
MovieOutputQueue queue;
|
|
const PngWriteSettings settings = {-1};
|
|
if (movie_output_queue_init(&queue, 1, &settings)) {
|
|
check(0, "failure after overlay: queue init");
|
|
rmdir(directory);
|
|
return;
|
|
}
|
|
MovieOutputJob job = make_job(0);
|
|
job.draw_mesh = 1;
|
|
if (make_layer(&job.mesh_layer, 4)) {
|
|
free(job.clean_rgb8);
|
|
check(0, "failure after overlay: layer allocation");
|
|
rmdir(directory);
|
|
movie_output_queue_destroy(&queue);
|
|
return;
|
|
}
|
|
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
|
|
snprintf(job.mesh_path, sizeof job.mesh_path,
|
|
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
|
|
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
|
|
free(job.clean_rgb8);
|
|
mesh_overlay_layer_destroy(&job.mesh_layer);
|
|
check(0, "failure after overlay: submit");
|
|
rmdir(directory);
|
|
movie_output_queue_destroy(&queue);
|
|
return;
|
|
}
|
|
check(movie_output_queue_finish(&queue) != 0,
|
|
"failure after overlay: mesh write error propagates");
|
|
unlink(clean_path);
|
|
rmdir(directory);
|
|
movie_output_queue_destroy(&queue);
|
|
}
|
|
|
|
int main(void) {
|
|
/* Standalone/CI runs need not have an OpenCode-created scratch directory. */
|
|
if (mkdir(TEST_TMP_ROOT, 0700) != 0 && errno != EEXIST) {
|
|
perror("create movie-output test scratch directory");
|
|
return 1;
|
|
}
|
|
test_order_and_pixels(1);
|
|
test_order_and_pixels(2);
|
|
test_backpressure();
|
|
test_writer_failure();
|
|
test_empty_paths();
|
|
test_lines_survive_source_release();
|
|
#ifdef ENABLE_PNG
|
|
test_default_writer_mesh();
|
|
#endif
|
|
test_default_writer_failure_before_overlay();
|
|
test_default_writer_failure_after_overlay();
|
|
if (failures != 0) {
|
|
fprintf(stderr, "%d movie-output failure(s)\n", failures);
|
|
return 1;
|
|
}
|
|
puts("movie-output tests passed");
|
|
return 0;
|
|
}
|