#include #include #include #include #include #include #include #include #include #include #include #include #include #include struct audio_probe { atomic_uint blocks; atomic_bool signal; }; static bool source_alpha_is_correct(obs_source_t *source) { bool correct = false; uint8_t solid[4] = {0}; uint8_t half[4] = {0}; uint8_t background[4] = {0}; obs_set_output_source(0, source); for (unsigned attempt = 0; attempt < 60 && !correct; attempt++) { usleep(100000); obs_enter_graphics(); gs_texrender_t *target = gs_texrender_create(GS_BGRA, GS_ZS_NONE); gs_stagesurf_t *stage = gs_stagesurface_create(64, 64, GS_BGRA); if (target && stage && gs_texrender_begin(target, 64, 64)) { struct vec4 clear = {0}; gs_clear(GS_CLEAR_COLOR, &clear, 0.0f, 0); gs_ortho(0.0f, 64.0f, 0.0f, 64.0f, -100.0f, 100.0f); bool previous_srgb = gs_set_linear_srgb(true); obs_source_video_render(source); gs_set_linear_srgb(previous_srgb); gs_texrender_end(target); gs_stage_texture(stage, gs_texrender_get_texture(target)); uint8_t *pixels = NULL; uint32_t stride = 0; if (gs_stagesurface_map(stage, &pixels, &stride)) { memcpy(background, pixels, sizeof(background)); memcpy(solid, pixels + (size_t)24 * stride + 24 * 4, sizeof(solid)); memcpy(half, pixels + (size_t)40 * stride + 40 * 4, sizeof(half)); gs_stagesurface_unmap(stage); correct = solid[0] < 32 && solid[1] < 40 && solid[2] > 225 && solid[3] == 255 && half[0] < 8 && half[1] < 8 && half[2] > 120 && half[2] < 136 && half[3] > 120 && half[3] < 136 && background[0] == 0 && background[1] == 0 && background[2] == 0 && background[3] == 0; } } gs_stagesurface_destroy(stage); gs_texrender_destroy(target); obs_leave_graphics(); } obs_set_output_source(0, NULL); if (!correct) fprintf(stderr, "OBS compositing changed browser alpha (solid BGRA=%u,%u,%u,%u; " "half BGRA=%u,%u,%u,%u; background BGRA=%u,%u,%u,%u)\n", solid[0], solid[1], solid[2], solid[3], half[0], half[1], half[2], half[3], background[0], background[1], background[2], background[3]); return correct; } static bool write_all(int fd, const void *data, size_t size) { const uint8_t *cursor = data; while (size) { ssize_t written = write(fd, cursor, size); if (written < 0 && errno == EINTR) continue; if (written <= 0) return false; cursor += (size_t)written; size -= (size_t)written; } return true; } static void write_u32_le(uint8_t *destination, uint32_t value) { for (unsigned index = 0; index < 4; index++) destination[index] = (uint8_t)(value >> (index * 8)); } static void write_u16_le(uint8_t *destination, uint16_t value) { destination[0] = (uint8_t)value; destination[1] = (uint8_t)(value >> 8); } static void serve_audio(int server) { static const char page[] = "" "" ""; const uint32_t frames = 48000; const uint32_t data_size = frames * 2 * sizeof(int16_t); uint8_t wav_header[44] = {0}; memcpy(wav_header, "RIFF", 4); write_u32_le(wav_header + 4, 36 + data_size); memcpy(wav_header + 8, "WAVEfmt ", 8); write_u32_le(wav_header + 16, 16); write_u16_le(wav_header + 20, 1); write_u16_le(wav_header + 22, 2); write_u32_le(wav_header + 24, 48000); write_u32_le(wav_header + 28, 48000 * 2 * sizeof(int16_t)); write_u16_le(wav_header + 32, 2 * sizeof(int16_t)); write_u16_le(wav_header + 34, 16); memcpy(wav_header + 36, "data", 4); write_u32_le(wav_header + 40, data_size); int16_t *samples = malloc(data_size); if (!samples) _exit(2); for (uint32_t frame = 0; frame < frames; frame++) { int16_t value = (frame / 55) % 2 ? 10000 : -10000; samples[frame * 2] = value; samples[frame * 2 + 1] = value; } for (;;) { int client = accept(server, NULL, NULL); if (client < 0 && errno == EINTR) continue; if (client < 0) _exit(0); char request[2048] = {0}; ssize_t count = read(client, request, sizeof(request) - 1); bool wav = count > 0 && strstr(request, "GET /tone.wav "); char response[512]; size_t body_size = wav ? sizeof(wav_header) + data_size : sizeof(page) - 1; int length = snprintf(response, sizeof(response), "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nCache-Control: no-store\r\n" "Connection: close\r\nContent-Length: %zu\r\n\r\n", wav ? "audio/wav" : "text/html", body_size); write_all(client, response, (size_t)length); if (wav) { write_all(client, wav_header, sizeof(wav_header)); write_all(client, samples, data_size); } else { write_all(client, page, sizeof(page) - 1); } close(client); } } static pid_t start_audio_server(char *url, size_t url_size) { int server = socket(AF_INET, SOCK_STREAM, 0); if (server < 0) return -1; struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; if (bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(server, 8) != 0) { close(server); return -1; } socklen_t size = sizeof(address); if (getsockname(server, (struct sockaddr *)&address, &size) != 0) { close(server); return -1; } pid_t child = fork(); if (child == 0) serve_audio(server); close(server); if (child < 0) return -1; snprintf(url, url_size, "http://127.0.0.1:%u/", ntohs(address.sin_port)); return child; } static void audio_captured(void *opaque, obs_source_t *source, const struct audio_data *audio, bool muted) { (void)source; (void)muted; struct audio_probe *probe = opaque; atomic_fetch_add(&probe->blocks, 1); if (!audio->data[0]) return; const float *samples = (const float *)audio->data[0]; for (uint32_t index = 0; index < audio->frames; index++) { float value = samples[index]; if (value > 0.01f || value < -0.01f) { atomic_store(&probe->signal, true); break; } } } int main(int argc, char **argv) { if (argc < 4 || argc > 5) { fprintf(stderr, "usage: %s plugin.so data-directory renderer [audio-test | alpha-test]\n", argv[0]); return 2; } bool audio_test = argc == 5 && strcmp(argv[4], "audio-test") == 0; bool alpha_test = argc == 5 && strcmp(argv[4], "alpha-test") == 0; char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX"; if (!mkdtemp(temporary)) { perror("mkdtemp"); return 2; } char renderer_link[512]; char locale_link[512]; char locale_source[512]; snprintf(renderer_link, sizeof(renderer_link), "%s/obs-webkit-renderer", temporary); snprintf(locale_link, sizeof(locale_link), "%s/locale", temporary); snprintf(locale_source, sizeof(locale_source), "%s/locale", argv[2]); if (symlink(argv[3], renderer_link) || symlink(locale_source, locale_link)) { perror("symlink"); return 2; } if (!obs_startup("en-US", NULL, NULL)) { fprintf(stderr, "obs_startup failed\n"); unlink(renderer_link); unlink(locale_link); rmdir(temporary); return 77; } struct obs_audio_info audio_info = {.samples_per_sec = 48000, .speakers = SPEAKERS_STEREO}; if (!obs_reset_audio(&audio_info)) { obs_shutdown(); return 77; } if (alpha_test) { struct obs_video_info video_info = { .graphics_module = "libobs-opengl", .fps_num = 30, .fps_den = 1, .base_width = 64, .base_height = 64, .output_width = 64, .output_height = 64, .output_format = VIDEO_FORMAT_BGRA, .gpu_conversion = false, .colorspace = VIDEO_CS_DEFAULT, .range = VIDEO_RANGE_FULL, .scale_type = OBS_SCALE_DISABLE, }; if (obs_reset_video(&video_info) != OBS_VIDEO_SUCCESS) { obs_shutdown(); return 77; } } char audio_url[128] = {0}; pid_t audio_server = audio_test ? start_audio_server(audio_url, sizeof(audio_url)) : -1; if (audio_test && audio_server < 0) { obs_shutdown(); return 77; } obs_module_t *module = NULL; int result = obs_open_module(&module, argv[1], temporary); if (result != MODULE_SUCCESS || !obs_init_module(module)) { fprintf(stderr, "module load failed: %d\n", result); if (audio_server > 0) { kill(audio_server, SIGTERM); waitpid(audio_server, NULL, 0); } obs_shutdown(); return 1; } const char *name = obs_source_get_display_name("webkit_browser_source"); uint32_t flags = obs_get_source_output_flags("webkit_browser_source"); bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) && (flags & OBS_SOURCE_AUDIO) && (flags & OBS_SOURCE_INTERACTION) && (flags & OBS_SOURCE_SRGB); if (!valid) fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n", name ? name : "(null)", flags); obs_data_t *settings = obs_data_create(); const char *test_url = "data:text/html,"; if (audio_test) test_url = audio_url; else if (alpha_test) test_url = "data:text/html,
"; obs_data_set_string(settings, "url", test_url); if (alpha_test) obs_data_set_string(settings, "css", "html,body{margin:0}#box{position:fixed;left:16px;top:16px;" "width:16px;height:16px;background:rgb(241,23,17)}" "#box:after{content:'';position:fixed;left:32px;top:32px;" "width:16px;height:16px;background:rgba(255,0,0,.5)}"); obs_data_set_int(settings, "width", 64); obs_data_set_int(settings, "height", 64); obs_data_set_int(settings, "fps", 10); obs_data_set_int(settings, "zoom", alpha_test ? 100 : 175); obs_data_set_bool(settings, "capture_audio", audio_test); obs_data_set_bool(settings, "transparent", true); obs_data_set_bool(settings, "hardware_acceleration", false); obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings); obs_data_release(settings); if (!source || obs_source_get_width(source) != 64 || obs_source_get_height(source) != 64) { fprintf(stderr, "source lifecycle setup failed\n"); valid = false; } obs_properties_t *properties = source ? obs_source_properties(source) : NULL; obs_property_t *zoom = properties ? obs_properties_get(properties, "zoom") : NULL; obs_property_t *capture_audio_property = properties ? obs_properties_get(properties, "capture_audio") : NULL; if (!zoom || obs_property_get_type(zoom) != OBS_PROPERTY_INT || obs_property_int_type(zoom) != OBS_NUMBER_SLIDER || obs_property_int_min(zoom) != 25 || obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) { fprintf(stderr, "page zoom property is missing or invalid\n"); valid = false; } if (!capture_audio_property || obs_property_get_type(capture_audio_property) != OBS_PROPERTY_BOOL || !source || obs_source_audio_active(source) != audio_test) { fprintf(stderr, "browser audio capture property or inactive mixer state is invalid\n"); valid = false; } if (properties) obs_properties_destroy(properties); struct audio_probe probe; atomic_init(&probe.blocks, 0); atomic_init(&probe.signal, false); if (source && audio_test) obs_source_add_audio_capture_callback(source, audio_captured, &probe); if (source) { if (alpha_test && !source_alpha_is_correct(source)) valid = false; unsigned attempts = audio_test ? 100 : 5; for (unsigned attempt = 0; attempt < attempts && (!audio_test || !atomic_load(&probe.signal)); attempt++) usleep(100000); if (audio_test) { obs_source_remove_audio_capture_callback(source, audio_captured, &probe); if (!atomic_load(&probe.signal)) { fprintf(stderr, "browser audio did not reach the OBS source (blocks=%u)\n", atomic_load(&probe.blocks)); valid = false; } } obs_source_release(source); } obs_shutdown(); unlink(renderer_link); unlink(locale_link); rmdir(temporary); if (audio_server > 0) { kill(audio_server, SIGTERM); waitpid(audio_server, NULL, 0); } return valid ? 0 : 1; }